init
This commit is contained in:
75
platform/core/.all-contributorsrc
Normal file
75
platform/core/.all-contributorsrc
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"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"]
|
||||
},
|
||||
{
|
||||
"login": "igoroctaviano",
|
||||
"name": "Igor Octaviano",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/13886933?v=4",
|
||||
"profile": "http://igoroctaviano.com",
|
||||
"contributions": ["code"]
|
||||
},
|
||||
{
|
||||
"login": "dlwire",
|
||||
"name": "David Wire",
|
||||
"avatar_url": "https://avatars3.githubusercontent.com/u/1167291?v=4",
|
||||
"profile": "https://github.com/dlwire",
|
||||
"contributions": ["code", "test"]
|
||||
},
|
||||
{
|
||||
"login": "pavertomato",
|
||||
"name": "Egor Lezhnin",
|
||||
"avatar_url": "https://avatars0.githubusercontent.com/u/878990?v=4",
|
||||
"profile": "http://egor.lezhn.in",
|
||||
"contributions": ["code"]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "@ohif/core",
|
||||
"projectOwner": "OHIF",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com"
|
||||
}
|
||||
12
platform/core/.webpack/webpack.dev.js
Normal file
12
platform/core/.webpack/webpack.dev.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.ts`,
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
};
|
||||
41
platform/core/.webpack/webpack.prod.js
Normal file
41
platform/core/.webpack/webpack.prod.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { merge } = require('webpack-merge');
|
||||
const path = require('path');
|
||||
const webpackCommon = require('./../../../.webpack/webpack.base.js');
|
||||
|
||||
const pkg = require('./../package.json');
|
||||
const ROOT_DIR = path.join(__dirname, './../');
|
||||
const SRC_DIR = path.join(__dirname, '../src');
|
||||
const DIST_DIR = path.join(__dirname, '../dist');
|
||||
|
||||
const ENTRY = {
|
||||
app: `${SRC_DIR}/index.ts`,
|
||||
};
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
|
||||
|
||||
return merge(commonConfig, {
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
timings: true,
|
||||
assets: true,
|
||||
chunks: false,
|
||||
chunkModules: false,
|
||||
modules: false,
|
||||
children: false,
|
||||
warnings: true,
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
sideEffects: false,
|
||||
},
|
||||
output: {
|
||||
path: ROOT_DIR,
|
||||
library: 'ohif-core',
|
||||
libraryTarget: 'umd',
|
||||
filename: pkg.main,
|
||||
},
|
||||
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@cornerstonejs/],
|
||||
});
|
||||
};
|
||||
3249
platform/core/CHANGELOG.md
Normal file
3249
platform/core/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
platform/core/LICENSE
Normal file
21
platform/core/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Open Health Imaging Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
136
platform/core/README.md
Normal file
136
platform/core/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<div align="center">
|
||||
<h1>@ohif/core</h1>
|
||||
<p><strong>@ohif/core</strong> is a collection of useful functions and classes for building web-based medical imaging applications. This library helps power OHIF's <a href="https://github.com/OHIF/Viewers">zero-footprint DICOM viewer</a>.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
[![NPM version][npm-version-image]][npm-url]
|
||||
[![NPM downloads][npm-downloads-image]][npm-url]
|
||||
[](#contributors)
|
||||
[![MIT License][license-image]][license-url]
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## 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/Viewers/blob/master/platform/core/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 releases 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.
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks goes to these wonderful people
|
||||
([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore -->
|
||||
<table><tr><td align="center"><a href="https://github.com/swederik"><img src="https://avatars3.githubusercontent.com/u/607793?v=4" width="100px;" alt="Erik Ziegler"/><br /><sub><b>Erik Ziegler</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=swederik" title="Code">💻</a></td><td align="center"><a href="https://github.com/evren217"><img src="https://avatars1.githubusercontent.com/u/4920551?v=4" width="100px;" alt="Evren Ozkan"/><br /><sub><b>Evren Ozkan</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=evren217" title="Code">💻</a></td><td align="center"><a href="https://github.com/galelis"><img src="https://avatars3.githubusercontent.com/u/2378326?v=4" width="100px;" alt="Gustavo André Lelis"/><br /><sub><b>Gustavo André Lelis</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=galelis" title="Code">💻</a></td><td align="center"><a href="http://dannyrb.com/"><img src="https://avatars1.githubusercontent.com/u/5797588?v=4" width="100px;" alt="Danny Brown"/><br /><sub><b>Danny Brown</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=dannyrb" title="Code">💻</a></td><td align="center"><a href="https://github.com/all-contributors/all-contributors-bot"><img src="https://avatars3.githubusercontent.com/u/46843839?v=4" width="100px;" alt="allcontributors[bot]"/><br /><sub><b>allcontributors[bot]</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=allcontributors" title="Documentation">📖</a></td><td align="center"><a href="https://github.com/ivan-aksamentov"><img src="https://avatars0.githubusercontent.com/u/9403403?v=4" width="100px;" alt="Ivan Aksamentov"/><br /><sub><b>Ivan Aksamentov</b></sub></a><br /><a href="https://github.com/OHIF/ohif-core/commits?author=ivan-aksamentov" title="Code">💻</a> <a href="https://github.com/OHIF/ohif-core/commits?author=ivan-aksamentov" title="Tests">⚠️</a></td></tr></table>
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
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)
|
||||
|
||||
<!--
|
||||
Links:
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- ROW -->
|
||||
[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
|
||||
[all-contributors-image]: https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square
|
||||
[license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square
|
||||
[license-url]: LICENSE
|
||||
<!-- Misc. -->
|
||||
[react-viewer]: https://github.com/OHIF/Viewers/tree/react
|
||||
<!-- prettier-ignore-end -->
|
||||
1
platform/core/babel.config.js
Normal file
1
platform/core/babel.config.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('../../babel.config.js');
|
||||
12
platform/core/jest.config.js
Normal file
12
platform/core/jest.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const base = require('../../jest.config.base.js');
|
||||
const pkg = require('./package');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
displayName: pkg.name,
|
||||
// rootDir: "../.."
|
||||
// testMatch: [
|
||||
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
|
||||
// "<rootDir>/platform/app/**/*.test.js"
|
||||
// ]
|
||||
};
|
||||
64
platform/core/package.json
Normal file
64
platform/core/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@ohif/core",
|
||||
"version": "3.9.1",
|
||||
"description": "Generic business logic for web-based medical imaging applications",
|
||||
"author": "OHIF Core Team",
|
||||
"license": "MIT",
|
||||
"repository": "OHIF/Viewers",
|
||||
"main": "dist/ohif-core.umd.js",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/types/index.ts",
|
||||
"sideEffects": "false",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"clean:deep": "yarn run clean && shx rm -rf node_modules",
|
||||
"dev": "jest --watchAll",
|
||||
"dev:core": "yarn run dev",
|
||||
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
|
||||
"build:package": "yarn run build",
|
||||
"start": "yarn run dev",
|
||||
"test:unit": "jest --watchAll",
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cornerstonejs/codec-charls": "^1.2.3",
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.4",
|
||||
"@cornerstonejs/codec-openjph": "^2.4.5",
|
||||
"@cornerstonejs/dicom-image-loader": "^2.2.4",
|
||||
"@ohif/ui": "3.9.1",
|
||||
"cornerstone-math": "0.1.9",
|
||||
"dicom-parser": "^1.8.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"dcmjs": "*",
|
||||
"dicomweb-client": "^0.10.4",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"isomorphic-base64": "^1.0.2",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
"moment": "*",
|
||||
"object-hash": "2.1.1",
|
||||
"query-string": "^6.14.0",
|
||||
"react-shepherd": "6.1.1",
|
||||
"shepherd.js": "13.0.3",
|
||||
"validate.js": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack-merge": "*"
|
||||
}
|
||||
}
|
||||
52
platform/core/src/DICOMWeb/getAttribute.js
Normal file
52
platform/core/src/DICOMWeb/getAttribute.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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) {
|
||||
const 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;
|
||||
}
|
||||
|
||||
let output = '';
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
for (let j = 0; j < input[i].length; j++) {
|
||||
output += padFour(input[i].charCodeAt(j).toString(16));
|
||||
}
|
||||
}
|
||||
|
||||
return parseInt(output, 16);
|
||||
}
|
||||
65
platform/core/src/DICOMWeb/getAttribute.test.js
Normal file
65
platform/core/src/DICOMWeb/getAttribute.test.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import getAttribute from './getAttribute';
|
||||
|
||||
describe('getAttribute', () => {
|
||||
it('should return a default value if element is null or undefined', () => {
|
||||
const defaultValue = '0000';
|
||||
const nullElement = null;
|
||||
const undefinedElement = undefined;
|
||||
|
||||
expect(getAttribute(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getAttribute(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return a default value if element.Value is null, undefined or not present', () => {
|
||||
const defaultValue = '0000';
|
||||
const nullElement = {
|
||||
id: 0,
|
||||
Value: null,
|
||||
};
|
||||
const undefinedElement = {
|
||||
id: 0,
|
||||
Value: undefined,
|
||||
};
|
||||
const noValuePresentElement = {
|
||||
id: 0,
|
||||
};
|
||||
|
||||
expect(getAttribute(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getAttribute(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getAttribute(noValuePresentElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return 48 for element with value 0', () => {
|
||||
const returnValue = 48;
|
||||
const element = {
|
||||
Value: '0',
|
||||
};
|
||||
expect(getAttribute(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return 3211313 for element with value 11', () => {
|
||||
const returnValue = 3211313;
|
||||
const element = {
|
||||
Value: '11',
|
||||
};
|
||||
expect(getAttribute(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return 2.4923405222191973e+35 for element with value 00280009', () => {
|
||||
const returnValue = 2.4923405222191973e35;
|
||||
const element = {
|
||||
id: 0,
|
||||
Value: '00280009',
|
||||
};
|
||||
expect(getAttribute(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return 2949169 for element with value -1', () => {
|
||||
const returnValue = 2949169;
|
||||
const element = {
|
||||
id: 0,
|
||||
Value: '-1',
|
||||
};
|
||||
expect(getAttribute(element, null)).toEqual(returnValue);
|
||||
});
|
||||
});
|
||||
35
platform/core/src/DICOMWeb/getAuthorizationHeader.js
Normal file
35
platform/core/src/DICOMWeb/getAuthorizationHeader.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'isomorphic-base64';
|
||||
import user from '../user';
|
||||
|
||||
/**
|
||||
* Returns the Authorization header as part of an Object.
|
||||
*
|
||||
* @export
|
||||
* @param {Object} [server={}]
|
||||
* @param {Object} [server.requestOptions]
|
||||
* @param {string|function} [server.requestOptions.auth]
|
||||
* @returns {Object} { Authorization }
|
||||
*/
|
||||
export default function getAuthorizationHeader({ requestOptions } = {}, user) {
|
||||
const headers = {};
|
||||
|
||||
// Check for OHIF.user since this can also be run on the server
|
||||
const accessToken = user && user.getAccessToken && user.getAccessToken();
|
||||
|
||||
// Auth for a specific server
|
||||
if (requestOptions && requestOptions.auth) {
|
||||
if (typeof requestOptions.auth === 'function') {
|
||||
// Custom Auth Header
|
||||
headers.Authorization = requestOptions.auth(requestOptions);
|
||||
} else {
|
||||
// HTTP Basic Auth (user:password)
|
||||
headers.Authorization = `Basic ${btoa(requestOptions.auth)}`;
|
||||
}
|
||||
}
|
||||
// Auth for the user's default
|
||||
else if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
72
platform/core/src/DICOMWeb/getAuthorizationHeader.test.js
Normal file
72
platform/core/src/DICOMWeb/getAuthorizationHeader.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import getAuthorizationHeader from './getAuthorizationHeader';
|
||||
import user from './../user';
|
||||
|
||||
jest.mock('./../user.js');
|
||||
|
||||
describe('getAuthorizationHeader', () => {
|
||||
it('should return a HTTP Basic Auth when server contains requestOptions.auth', () => {
|
||||
const validServer = {
|
||||
requestOptions: {
|
||||
auth: 'dummy_user:dummy_password',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAuthorizationHeader = {
|
||||
Authorization: `Basic ${btoa(validServer.requestOptions.auth)}`,
|
||||
};
|
||||
|
||||
const authentication = getAuthorizationHeader(validServer);
|
||||
|
||||
expect(authentication).toEqual(expectedAuthorizationHeader);
|
||||
});
|
||||
|
||||
it('should return a HTTP Basic Auth when server contains requestOptions.auth even though there is no password', () => {
|
||||
const validServerWithoutPassword = {
|
||||
requestOptions: {
|
||||
auth: 'dummy_user',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAuthorizationHeader = {
|
||||
Authorization: `Basic ${btoa(validServerWithoutPassword.requestOptions.auth)}`,
|
||||
};
|
||||
|
||||
const authentication = getAuthorizationHeader(validServerWithoutPassword);
|
||||
|
||||
expect(authentication).toEqual(expectedAuthorizationHeader);
|
||||
});
|
||||
|
||||
it('should return a HTTP Basic Auth when server contains requestOptions.auth custom function', () => {
|
||||
const validServerCustomAuth = {
|
||||
requestOptions: {
|
||||
auth: options => `Basic ${options.token}`,
|
||||
token: 'ZHVtbXlfdXNlcjpkdW1teV9wYXNzd29yZA==',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAuthorizationHeader = {
|
||||
Authorization: `Basic ${validServerCustomAuth.requestOptions.token}`,
|
||||
};
|
||||
|
||||
const authentication = getAuthorizationHeader(validServerCustomAuth);
|
||||
|
||||
expect(authentication).toEqual(expectedAuthorizationHeader);
|
||||
});
|
||||
|
||||
it('should return an empty object when there is no either server.requestOptions.auth or accessToken', () => {
|
||||
const authentication = getAuthorizationHeader({});
|
||||
|
||||
expect(authentication).toEqual({});
|
||||
});
|
||||
|
||||
it('should return an Authorization with accessToken when server is not defined and there is an accessToken', () => {
|
||||
user.getAccessToken.mockImplementationOnce(() => 'MOCKED_TOKEN');
|
||||
|
||||
const authentication = getAuthorizationHeader({}, user);
|
||||
const expectedHeaderBasedOnUserAccessToken = {
|
||||
Authorization: 'Bearer MOCKED_TOKEN',
|
||||
};
|
||||
|
||||
expect(authentication).toEqual(expectedHeaderBasedOnUserAccessToken);
|
||||
});
|
||||
});
|
||||
29
platform/core/src/DICOMWeb/getModalities.js
Normal file
29
platform/core/src/DICOMWeb/getModalities.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export default function getModalities(Modality, ModalitiesInStudy) {
|
||||
if (!Modality && !ModalitiesInStudy) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const modalities = Modality || {
|
||||
vr: 'CS',
|
||||
Value: [],
|
||||
};
|
||||
|
||||
// Rare case, depending on the DICOM server we are using, but sometimes,
|
||||
// modalities.Value is undefined or null.
|
||||
modalities.Value = modalities.Value || [];
|
||||
|
||||
if (ModalitiesInStudy) {
|
||||
if (modalities.vr && modalities.vr === ModalitiesInStudy.vr) {
|
||||
for (let i = 0; i < ModalitiesInStudy.Value.length; i++) {
|
||||
const value = ModalitiesInStudy.Value[i];
|
||||
if (modalities.Value.indexOf(value) === -1) {
|
||||
modalities.Value.push(value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return ModalitiesInStudy;
|
||||
}
|
||||
}
|
||||
|
||||
return modalities;
|
||||
}
|
||||
70
platform/core/src/DICOMWeb/getModalities.test.js
Normal file
70
platform/core/src/DICOMWeb/getModalities.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import getModalities from './getModalities';
|
||||
|
||||
describe('getModalities', () => {
|
||||
test('should return an empty object when Modality and ModalitiesInStudy are not present', () => {
|
||||
const Modality = null;
|
||||
const ModalitiesInStudy = null;
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual({});
|
||||
});
|
||||
|
||||
test('should return an empty object when Modality and ModalitiesInStudy are not present', () => {
|
||||
const Modality = null;
|
||||
const ModalitiesInStudy = null;
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual({});
|
||||
});
|
||||
|
||||
test('should return modalities in Study when Modality is not defined', () => {
|
||||
const Modality = null;
|
||||
|
||||
const ModalitiesInStudy = {
|
||||
Value: ['MOCKED_VALUE'],
|
||||
vr: 'MOCKED_VALUE',
|
||||
};
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(ModalitiesInStudy);
|
||||
});
|
||||
|
||||
test('should return only the modalitues that exists in ModalitiesInStudy', () => {
|
||||
const Modality = {
|
||||
Value: ['DESIRED_VALUE'],
|
||||
vr: 'DESIRED_VR',
|
||||
};
|
||||
|
||||
const ModalitiesInStudy = {
|
||||
Value: ['DESIRED_VALUE', 'NOT_DESIRED_VALUE'],
|
||||
vr: 'DESIRED_VR',
|
||||
};
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(Modality);
|
||||
});
|
||||
|
||||
test('should return the seek Modality when the desired Modality does not exist in ModalitiesInStudy', () => {
|
||||
const Modality = {
|
||||
Value: ['DESIRED_VALUE'],
|
||||
vr: 'DESIRED_VR',
|
||||
};
|
||||
|
||||
const ModalitiesInStudy = {
|
||||
Value: ['NOT_DESIRED_VALUE'],
|
||||
vr: 'DESIRED_VR',
|
||||
};
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(Modality);
|
||||
});
|
||||
|
||||
test('should return the seek Modality when the desired Modality does not exist in ModalitiesInStudy VR', () => {
|
||||
const Modality = {
|
||||
Value: ['DESIRED_VALUE'],
|
||||
vr: 'DESIRED_VR',
|
||||
};
|
||||
|
||||
const ModalitiesInStudy = {
|
||||
Value: ['NOT_DESIRED_VALUE'],
|
||||
vr: 'ANOTHER_VR',
|
||||
};
|
||||
|
||||
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(ModalitiesInStudy);
|
||||
});
|
||||
});
|
||||
26
platform/core/src/DICOMWeb/getName.js
Normal file
26
platform/core/src/DICOMWeb/getName.js
Normal file
@@ -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];
|
||||
}
|
||||
57
platform/core/src/DICOMWeb/getName.test.js
Normal file
57
platform/core/src/DICOMWeb/getName.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import getName from './getName';
|
||||
|
||||
describe('getName', () => {
|
||||
it('should return a default value if element is null or undefined', () => {
|
||||
const defaultValue = 'DEFAULT_NAME';
|
||||
const nullElement = null;
|
||||
const undefinedElement = undefined;
|
||||
|
||||
expect(getName(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getName(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return a default value if element.Value is null, undefined or not present', () => {
|
||||
const defaultValue = 'DEFAULT_NAME';
|
||||
const nullElement = {
|
||||
id: 0,
|
||||
Value: null,
|
||||
};
|
||||
const undefinedElement = {
|
||||
id: 0,
|
||||
Value: undefined,
|
||||
};
|
||||
const noValuePresentElement = {
|
||||
id: 0,
|
||||
};
|
||||
|
||||
expect(getName(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getName(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getName(noValuePresentElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return A for element when Alphabetic is [A, B, C, D]', () => {
|
||||
const returnValue = 'A';
|
||||
const element = {
|
||||
Value: [{ Alphabetic: 'A' }, { Alphabetic: 'B' }, { Alphabetic: 'C' }, { Alphabetic: 'D' }],
|
||||
};
|
||||
expect(getName(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return FIRST for element when Alphabetic is [FIRST, SECOND]', () => {
|
||||
const returnValue = 'FIRST';
|
||||
const element = {
|
||||
Value: [{ Alphabetic: 'FIRST' }, { Alphabetic: 'SECOND' }],
|
||||
};
|
||||
expect(getName(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return element.value[0] for element with not Alphabetic and when there is at least on element.Value', () => {
|
||||
const returnValue = {
|
||||
anyOtherProperty: 'FIRST',
|
||||
};
|
||||
const element = {
|
||||
Value: [{ anyOtherProperty: 'FIRST' }, { Alphabetic: 'SECOND' }],
|
||||
};
|
||||
expect(getName(element, null)).toEqual(returnValue);
|
||||
});
|
||||
});
|
||||
21
platform/core/src/DICOMWeb/getNumber.js
Normal file
21
platform/core/src/DICOMWeb/getNumber.js
Normal file
@@ -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]);
|
||||
}
|
||||
55
platform/core/src/DICOMWeb/getNumber.test.js
Normal file
55
platform/core/src/DICOMWeb/getNumber.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import getNumber from './getNumber';
|
||||
|
||||
describe('getNumber', () => {
|
||||
it('should return a default value if element is null or undefined', () => {
|
||||
const defaultValue = 1.0;
|
||||
const nullElement = null;
|
||||
const undefinedElement = undefined;
|
||||
|
||||
expect(getNumber(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getNumber(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return a default value if element.Value is null, undefined or not present', () => {
|
||||
const defaultValue = 1.0;
|
||||
const nullElement = {
|
||||
id: 0,
|
||||
Value: null,
|
||||
};
|
||||
const undefinedElement = {
|
||||
id: 0,
|
||||
Value: undefined,
|
||||
};
|
||||
const noValuePresentElement = {
|
||||
id: 0,
|
||||
};
|
||||
|
||||
expect(getNumber(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getNumber(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getNumber(noValuePresentElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return 2.0 for element when element.Value[0] = 2', () => {
|
||||
const returnValue = 2.0;
|
||||
const element = {
|
||||
Value: ['2'],
|
||||
};
|
||||
expect(getNumber(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return -1.0 for element when element.Value[0] is -1', () => {
|
||||
const returnValue = -1.0;
|
||||
const element = {
|
||||
Value: ['-1'],
|
||||
};
|
||||
expect(getNumber(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return -1.0 for element when element.Value is [-1, 2, 5, -10] ', () => {
|
||||
const returnValue = -1.0;
|
||||
const element = {
|
||||
Value: ['-1', '2', '5', '-10'],
|
||||
};
|
||||
expect(getNumber(element, null)).toEqual(returnValue);
|
||||
});
|
||||
});
|
||||
23
platform/core/src/DICOMWeb/getString.js
Normal file
23
platform/core/src/DICOMWeb/getString.js
Normal file
@@ -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('\\');
|
||||
}
|
||||
55
platform/core/src/DICOMWeb/getString.test.js
Normal file
55
platform/core/src/DICOMWeb/getString.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import getString from './getString';
|
||||
|
||||
describe('getString', () => {
|
||||
it('should return a default value if element is null or undefined', () => {
|
||||
const defaultValue = ['A', 'B', 'C'].join('\\');
|
||||
const nullElement = null;
|
||||
const undefinedElement = undefined;
|
||||
|
||||
expect(getString(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getString(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return a default value if element.Value is null, undefined or not present', () => {
|
||||
const defaultValue = ['A', 'B', 'C'].join('\\');
|
||||
const nullElement = {
|
||||
id: 0,
|
||||
Value: null,
|
||||
};
|
||||
const undefinedElement = {
|
||||
id: 0,
|
||||
Value: undefined,
|
||||
};
|
||||
const noValuePresentElement = {
|
||||
id: 0,
|
||||
};
|
||||
|
||||
expect(getString(nullElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getString(undefinedElement, defaultValue)).toEqual(defaultValue);
|
||||
expect(getString(noValuePresentElement, defaultValue)).toEqual(defaultValue);
|
||||
});
|
||||
|
||||
it('should return A,B,C,D for element when element.Value[0] = [A, B, C, D]', () => {
|
||||
const returnValue = ['A', 'B', 'C'].join('\\');
|
||||
const element = {
|
||||
Value: ['A', 'B', 'C'],
|
||||
};
|
||||
expect(getString(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return 1,4,5,6 for element when element.Value[0] is [1, 4, 5, 6]', () => {
|
||||
const returnValue = [1, 4, 5, 6].join('\\');
|
||||
const element = {
|
||||
Value: [1, 4, 5, 6],
|
||||
};
|
||||
expect(getString(element, null)).toEqual(returnValue);
|
||||
});
|
||||
|
||||
it('should return A,1,3,R,7,-1 for element when element.Value is [-1, 2, 5, -10] ', () => {
|
||||
const returnValue = ['A', '1', '3', 'R', '7', '-1'].join('\\');
|
||||
const element = {
|
||||
Value: ['A', '1', '3', 'R', '7', '-1'],
|
||||
};
|
||||
expect(getString(element, null)).toEqual(returnValue);
|
||||
});
|
||||
});
|
||||
19
platform/core/src/DICOMWeb/index.js
Normal file
19
platform/core/src/DICOMWeb/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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 { getAttribute, getAuthorizationHeader, getModalities, getName, getNumber, getString };
|
||||
|
||||
export default DICOMWeb;
|
||||
18
platform/core/src/DICOMWeb/index.test.js
Normal file
18
platform/core/src/DICOMWeb/index.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as DICOMWeb from './index.js';
|
||||
|
||||
describe('Top level exports', () => {
|
||||
test('should export the modules getAttribute, getAuthorizationHeader, getModalities, getName, getNumber, getString', () => {
|
||||
const expectedExports = [
|
||||
'getAttribute',
|
||||
'getAuthorizationHeader',
|
||||
'getModalities',
|
||||
'getName',
|
||||
'getNumber',
|
||||
'getString',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(DICOMWeb.default).sort();
|
||||
|
||||
expect(exports).toEqual(expectedExports);
|
||||
});
|
||||
});
|
||||
85
platform/core/src/DataSources/IWebApiDataSource.js
Normal file
85
platform/core/src/DataSources/IWebApiDataSource.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { DicomMetadataStore } from '../services/DicomMetadataStore';
|
||||
// TODO: Use above to inject so dependent datasources don't need to import or
|
||||
// depend on @ohif/core?
|
||||
|
||||
/**
|
||||
* Factory function that creates a new "Web API" data source.
|
||||
* A "Web API" data source is any source that fetches data over
|
||||
* HTTP. This function serves as an "adapter" to wrap those calls
|
||||
* so that all "Web API" data sources have the same interface and can
|
||||
* be used interchangeably.
|
||||
*
|
||||
* It's worth noting that a single implementation of this interface
|
||||
* can define different underlying sources for "read" and "write" operations.
|
||||
*/
|
||||
function create({
|
||||
query,
|
||||
retrieve,
|
||||
store,
|
||||
reject,
|
||||
initialize,
|
||||
deleteStudyMetadataPromise,
|
||||
getImageIdsForDisplaySet,
|
||||
getImageIdsForInstance,
|
||||
getConfig,
|
||||
getStudyInstanceUIDs,
|
||||
}) {
|
||||
const defaultQuery = {
|
||||
studies: {
|
||||
/**
|
||||
* @param {string} params.patientName
|
||||
* @param {string} params.mrn
|
||||
* @param {object} params.studyDate
|
||||
* @param {string} params.description
|
||||
* @param {string} params.modality
|
||||
* @param {string} params.accession
|
||||
* @param {string} params.sortBy
|
||||
* @param {string} params.sortDirection -
|
||||
* @param {number} params.page
|
||||
* @param {number} params.resultsPerPage
|
||||
*/
|
||||
mapParams: params => params,
|
||||
requestResults: () => {},
|
||||
processResults: results => results,
|
||||
},
|
||||
series: {},
|
||||
instances: {},
|
||||
};
|
||||
|
||||
const defaultRetrieve = {
|
||||
series: {},
|
||||
};
|
||||
|
||||
const defaultStore = {
|
||||
dicom: async naturalizedDataset => {
|
||||
throw new Error(
|
||||
'store.dicom(naturalizedDicom, StudyInstanceUID) not implemented for dataSource.'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const defaultReject = {};
|
||||
|
||||
const defaultGetConfig = () => {
|
||||
return { dicomUploadEnabled: false };
|
||||
};
|
||||
|
||||
return {
|
||||
query: query || defaultQuery,
|
||||
retrieve: retrieve || defaultRetrieve,
|
||||
reject: reject || defaultReject,
|
||||
store: store || defaultStore,
|
||||
initialize,
|
||||
deleteStudyMetadataPromise,
|
||||
getImageIdsForDisplaySet,
|
||||
getImageIdsForInstance,
|
||||
getConfig: getConfig || defaultGetConfig,
|
||||
getStudyInstanceUIDs: getStudyInstanceUIDs,
|
||||
};
|
||||
}
|
||||
|
||||
const IWebApiDataSource = {
|
||||
create,
|
||||
};
|
||||
|
||||
export default IWebApiDataSource;
|
||||
1
platform/core/src/__mocks__/cornerstone-core.js
Normal file
1
platform/core/src/__mocks__/cornerstone-core.js
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
1
platform/core/src/__mocks__/cornerstone-tools.js
Normal file
1
platform/core/src/__mocks__/cornerstone-tools.js
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
1
platform/core/src/__mocks__/dicom-parser.js
Normal file
1
platform/core/src/__mocks__/dicom-parser.js
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
17
platform/core/src/__mocks__/dicomweb-client.js
Normal file
17
platform/core/src/__mocks__/dicomweb-client.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// import { api } from 'dicomweb-client'
|
||||
|
||||
const api = {
|
||||
DICOMwebClient: jest.fn().mockImplementation(function () {
|
||||
this.retrieveStudyMetadata = jest.fn().mockResolvedValue([]);
|
||||
this.retrieveSeriesMetadata = jest.fn(function (options) {
|
||||
const { studyInstanceUID, seriesInstanceUID } = options;
|
||||
return Promise.resolve([{ studyInstanceUID, seriesInstanceUID }]);
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
export default {
|
||||
api,
|
||||
};
|
||||
|
||||
export { api };
|
||||
5
platform/core/src/__mocks__/log.js
Normal file
5
platform/core/src/__mocks__/log.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
191
platform/core/src/classes/CommandsManager.test.js
Normal file
191
platform/core/src/classes/CommandsManager.test.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import CommandsManager from './CommandsManager';
|
||||
import log from './../log.js';
|
||||
|
||||
jest.mock('./../log.js');
|
||||
|
||||
describe('CommandsManager', () => {
|
||||
let commandsManager,
|
||||
contextName = 'VTK',
|
||||
command = {
|
||||
commandFn: jest.fn().mockReturnValue(true),
|
||||
options: { passMeToCommandFn: ':wave:' },
|
||||
},
|
||||
commandsManagerConfig = {
|
||||
getAppState: () => {
|
||||
return {
|
||||
viewers: 'Test',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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({});
|
||||
});
|
||||
|
||||
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,
|
||||
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/ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
231
platform/core/src/classes/CommandsManager.ts
Normal file
231
platform/core/src/classes/CommandsManager.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import log from '../log.js';
|
||||
import { Command, Commands, ComplexCommand } from '../types/Command';
|
||||
|
||||
/**
|
||||
* The definition of a command
|
||||
*
|
||||
* @typedef {Object} CommandDefinition
|
||||
* @property {Function} commandFn - Command to call
|
||||
* @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({} = {}) {
|
||||
this.contexts = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns {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: string, contextName?: string) => {
|
||||
const contexts = [];
|
||||
|
||||
if (contextName) {
|
||||
const context = this.getContext(contextName);
|
||||
if (context) {
|
||||
contexts.push(context);
|
||||
}
|
||||
} else {
|
||||
Object.keys(this.contexts).forEach(contextName => {
|
||||
contexts.push(this.getContext(contextName));
|
||||
});
|
||||
}
|
||||
|
||||
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]
|
||||
*/
|
||||
public runCommand(commandName: string, options = {}, contextName?: string) {
|
||||
const definition = this.getCommand(commandName, contextName);
|
||||
if (!definition) {
|
||||
log.warn(`Command "${commandName}" not found in current context`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { commandFn } = definition;
|
||||
const commandParams = Object.assign(
|
||||
{},
|
||||
definition.options, // "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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one or more commands with specified extra options.
|
||||
* Returns the result of the last command run.
|
||||
*
|
||||
* @param toRun - A specification of one or more commands,
|
||||
* typically an object of { commandName, commandOptions, context }
|
||||
* or an array of such objects. It can also be a single commandName as string
|
||||
* if no options are needed.
|
||||
* @param options - to include in the commands run beyond
|
||||
* the commandOptions specified in the base.
|
||||
*/
|
||||
public run(
|
||||
toRun: Command | Commands | Command[] | string | undefined,
|
||||
options?: Record<string, unknown>
|
||||
): unknown {
|
||||
if (!toRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize `toRun` to an array of `ComplexCommand`
|
||||
let commands: ComplexCommand[] = [];
|
||||
if (typeof toRun === 'string') {
|
||||
commands = [{ commandName: toRun }];
|
||||
} else if ('commandName' in toRun) {
|
||||
commands = [toRun as ComplexCommand];
|
||||
} else if ('commands' in toRun) {
|
||||
const commandsInput = (toRun as Commands).commands;
|
||||
commands = Array.isArray(commandsInput)
|
||||
? commandsInput.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd))
|
||||
: [{ commandName: commandsInput }];
|
||||
} else if (Array.isArray(toRun)) {
|
||||
commands = toRun.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd));
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
console.log("Command isn't runnable", toRun);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute each command in the array
|
||||
let result: unknown;
|
||||
commands.forEach(command => {
|
||||
const { commandName, commandOptions, context } = command;
|
||||
if (commandName) {
|
||||
result = this.runCommand(
|
||||
commandName,
|
||||
{
|
||||
...commandOptions,
|
||||
...options,
|
||||
},
|
||||
context
|
||||
);
|
||||
} else {
|
||||
if (typeof command === 'function') {
|
||||
result = command();
|
||||
} else {
|
||||
console.warn('No command name supplied in', toRun);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandsManager;
|
||||
8
platform/core/src/classes/Hotkey.ts
Normal file
8
platform/core/src/classes/Hotkey.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default interface Hotkey {
|
||||
commandName: string;
|
||||
commandOptions?: Record<string, unknown>;
|
||||
context?: string;
|
||||
keys: string[];
|
||||
label: string;
|
||||
isEditable?: boolean;
|
||||
}
|
||||
198
platform/core/src/classes/HotkeysManager.test.js
Normal file
198
platform/core/src/classes/HotkeysManager.test.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import CommandsManager from './CommandsManager';
|
||||
import HotkeysManager from './HotkeysManager';
|
||||
import hotkeys from './../utils/hotkeys';
|
||||
import log from './../log';
|
||||
import objectHash from 'object-hash';
|
||||
|
||||
jest.mock('./CommandsManager');
|
||||
jest.mock('./../utils/hotkeys');
|
||||
jest.mock('./../log');
|
||||
|
||||
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 expectedProperties = ['hotkeyDefinitions', 'hotkeyDefaults', 'isEnabled'];
|
||||
|
||||
const containsAllExpectedProperties = expectedProperties.every(expected =>
|
||||
allProperties.includes(expected)
|
||||
);
|
||||
|
||||
expect(containsAllExpectedProperties).toBe(true);
|
||||
});
|
||||
|
||||
it('throws Error if instantiated without a commandsManager', () => {
|
||||
expect(() => {
|
||||
new HotkeysManager();
|
||||
}).toThrow(
|
||||
'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 = jest.fn();
|
||||
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 when calling setHotKeys', () => {
|
||||
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
|
||||
|
||||
hotkeysManager.setHotkeys(hotkeyDefinitions);
|
||||
|
||||
expect(hotkeysManager.hotkeyDefaults).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDefaultHotKeys()', () => {
|
||||
it('it sets default hotkeys', () => {
|
||||
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
|
||||
|
||||
hotkeysManager.setDefaultHotKeys(hotkeyDefinitions);
|
||||
|
||||
expect(hotkeysManager.hotkeyDefaults).toEqual(hotkeyDefinitions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerHotkeys()', () => {
|
||||
it('throws an Error if a commandName is not provided', () => {
|
||||
const definition = { commandName: undefined, keys: '+' };
|
||||
|
||||
expect(() => {
|
||||
hotkeysManager.registerHotkeys(definition);
|
||||
}).toThrow();
|
||||
});
|
||||
it('updates hotkeyDefinitions property with registered keys', () => {
|
||||
const definition = {
|
||||
commandName: 'dance',
|
||||
commandOptions: {},
|
||||
label: 'hello',
|
||||
keys: '+',
|
||||
};
|
||||
|
||||
hotkeysManager.registerHotkeys(definition);
|
||||
|
||||
const numOfHotkeyDefinitions = Object.keys(hotkeysManager.hotkeyDefinitions).length;
|
||||
|
||||
const commandHash = objectHash({
|
||||
commandName: definition.commandName,
|
||||
commandOptions: definition.commandOptions,
|
||||
});
|
||||
const hotkeyDefinitionForRegisteredCommand = hotkeysManager.hotkeyDefinitions[commandHash];
|
||||
|
||||
expect(numOfHotkeyDefinitions).toBe(1);
|
||||
expect(Object.keys(hotkeysManager.hotkeyDefinitions)[0]).toEqual(commandHash);
|
||||
expect(hotkeyDefinitionForRegisteredCommand).toEqual(definition);
|
||||
});
|
||||
it('calls hotkeys.bind for the group of keys', () => {
|
||||
const definition = { commandName: 'dance', keys: ['shift', 'e'] };
|
||||
|
||||
hotkeysManager.registerHotkeys(definition);
|
||||
|
||||
expect(hotkeys.bind.mock.calls.length).toBe(1);
|
||||
expect(hotkeys.bind.mock.calls[0][0]).toBe('shift+e');
|
||||
});
|
||||
it('calls hotkeys.unbind if commandName was previously registered, for each previously registered set of keys', () => {
|
||||
const firstDefinition = {
|
||||
commandName: 'dance',
|
||||
keys: ['alt', 'e'],
|
||||
};
|
||||
const secondDefinition = { commandName: 'dance', keys: 'a' };
|
||||
|
||||
// First call
|
||||
hotkeysManager.registerHotkeys(firstDefinition);
|
||||
// Second call
|
||||
hotkeysManager.registerHotkeys(secondDefinition);
|
||||
|
||||
expect(hotkeys.unbind.mock.calls.length).toBe(1);
|
||||
expect(hotkeys.unbind.mock.calls[0][0]).toBe('alt+e');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
276
platform/core/src/classes/HotkeysManager.ts
Normal file
276
platform/core/src/classes/HotkeysManager.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import objectHash from 'object-hash';
|
||||
import { hotkeys } from '../utils';
|
||||
import isequal from 'lodash.isequal';
|
||||
import Hotkey from './Hotkey';
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @typedef {Object} HotkeyDefinition
|
||||
* @property {String} commandName - Command to call
|
||||
* @property {Object} commandOptions - Command options
|
||||
* @property {String} label - Display name for hotkey
|
||||
* @property {String[]} keys - Keys to bind; Follows Mousetrap.js binding syntax
|
||||
*/
|
||||
|
||||
export class HotkeysManager {
|
||||
private _servicesManager: AppTypes.ServicesManager;
|
||||
|
||||
constructor(commandsManager, servicesManager: AppTypes.ServicesManager) {
|
||||
this.hotkeyDefinitions = {};
|
||||
this.hotkeyDefaults = [];
|
||||
this.isEnabled = true;
|
||||
|
||||
if (!commandsManager) {
|
||||
throw new Error(
|
||||
'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
|
||||
);
|
||||
}
|
||||
|
||||
this._servicesManager = servicesManager;
|
||||
this._commandsManager = commandsManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes Mousetrap.js's `.record` method, added by the record plugin.
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
record(event) {
|
||||
return hotkeys.record(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
setHotkeys(hotkeyDefinitions = [], name = 'hotkey-definitions') {
|
||||
try {
|
||||
const definitions = this.getValidDefinitions(hotkeyDefinitions);
|
||||
if (isequal(definitions, this.hotkeyDefaults)) {
|
||||
localStorage.removeItem(name);
|
||||
} else {
|
||||
localStorage.setItem(name, JSON.stringify(definitions));
|
||||
}
|
||||
definitions.forEach(definition => this.registerHotkeys(definition));
|
||||
} catch (error) {
|
||||
const { uiNotificationService } = this._servicesManager.services;
|
||||
uiNotificationService.show({
|
||||
title: 'Hotkeys Manager',
|
||||
message: 'Error while setting hotkeys',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default hotkey bindings. These
|
||||
* values are used in `this.restoreDefaultBindings`.
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
setDefaultHotKeys(hotkeyDefinitions = []) {
|
||||
const definitions = this.getValidDefinitions(hotkeyDefinitions);
|
||||
this.hotkeyDefaults = definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take hotkey definitions that can be an array or object and make sure that it
|
||||
* returns an array of hotkeys
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
*/
|
||||
getValidDefinitions(hotkeyDefinitions) {
|
||||
const definitions = Array.isArray(hotkeyDefinitions)
|
||||
? [...hotkeyDefinitions]
|
||||
: this._parseToArrayLike(hotkeyDefinitions);
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take hotkey definitions that can be an array and make sure that it
|
||||
* returns an object of hotkeys definitions
|
||||
*
|
||||
* @param {HotkeyDefinition[]} [hotkeyDefinitions=[]] Contains hotkeys definitions
|
||||
* @returns {Object}
|
||||
*/
|
||||
getValidHotkeyDefinitions(hotkeyDefinitions) {
|
||||
const definitions = this.getValidDefinitions(hotkeyDefinitions);
|
||||
const objectDefinitions = {};
|
||||
definitions.forEach(definition => {
|
||||
const { commandName, commandOptions } = definition;
|
||||
const commandHash = objectHash({ commandName, commandOptions });
|
||||
objectDefinitions[commandHash] = definition;
|
||||
});
|
||||
return objectDefinitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* It parses given object containing hotkeyDefinition to array like.
|
||||
* Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions={}] Contains hotkeys definitions
|
||||
* @returns {HotkeyDefinition[]}
|
||||
*/
|
||||
_parseToArrayLike(hotkeyDefinitionsObj = {}) {
|
||||
const copy = { ...hotkeyDefinitionsObj };
|
||||
return Object.entries(copy).map(entryValue =>
|
||||
this._parseToHotKeyObj(entryValue[0], entryValue[1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return HotkeyDefinition object like based on given property name and property value
|
||||
* @param {string} propertyName property name of hotkey definition object
|
||||
* @param {object} propertyValue property value of hotkey definition object
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const hotKeyObj = {hotKeyDefA: {keys:[],....}}
|
||||
*
|
||||
* const parsed = _parseToHotKeyObj(Object.keys(hotKeyDefA)[0], hotKeyObj[hotKeyDefA]);
|
||||
* {
|
||||
* commandName: hotKeyDefA,
|
||||
* keys: [],
|
||||
* ....
|
||||
* }
|
||||
*
|
||||
*/
|
||||
_parseToHotKeyObj(propertyName, propertyValue) {
|
||||
return {
|
||||
commandName: propertyName,
|
||||
...propertyValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* (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} command
|
||||
* @param {String} extension
|
||||
* @returns {undefined}
|
||||
*/
|
||||
registerHotkeys(
|
||||
{ commandName, commandOptions = {}, context, keys, label, isEditable }: Hotkey = {},
|
||||
extension
|
||||
) {
|
||||
if (!commandName) {
|
||||
throw new Error(`No command was defined for hotkey "${keys}"`);
|
||||
}
|
||||
|
||||
const commandHash = objectHash({ commandName, commandOptions });
|
||||
const options = Object.keys(commandOptions).length ? JSON.stringify(commandOptions) : 'no';
|
||||
const previouslyRegisteredDefinition = this.hotkeyDefinitions[commandHash];
|
||||
|
||||
if (previouslyRegisteredDefinition) {
|
||||
const previouslyRegisteredKeys = previouslyRegisteredDefinition.keys;
|
||||
this._unbindHotkeys(commandName, previouslyRegisteredKeys);
|
||||
// log.info(
|
||||
// `[hotkeys] Unbinding ${commandName} with ${options} options from ${previouslyRegisteredKeys}`
|
||||
// );
|
||||
}
|
||||
|
||||
// Set definition & bind
|
||||
this.hotkeyDefinitions[commandHash] = {
|
||||
commandName,
|
||||
commandOptions,
|
||||
keys,
|
||||
label,
|
||||
isEditable,
|
||||
};
|
||||
this._bindHotkeys(commandName, commandOptions, context, keys);
|
||||
// log.info(
|
||||
// `[hotkeys] Binding ${commandName} with ${options} from ${context ||
|
||||
// 'default'} options to ${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, commandOptions = {}, context, keys) {
|
||||
const isKeyDefined = keys === '' || keys === undefined;
|
||||
if (isKeyDefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isKeyArray = keys instanceof Array;
|
||||
const combinedKeys = isKeyArray ? keys.join('+') : keys;
|
||||
|
||||
hotkeys.bind(combinedKeys, evt => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this._commandsManager.runCommand(commandName, { evt, ...commandOptions }, context);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const combinedKeys = keys.join('+');
|
||||
this._unbindHotkeys(commandName, combinedKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
hotkeys.unbind(keys);
|
||||
}
|
||||
}
|
||||
|
||||
export default HotkeysManager;
|
||||
141
platform/core/src/classes/ImageSet.ts
Normal file
141
platform/core/src/classes/ImageSet.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import guid from '../utils/guid.js';
|
||||
import { Vector3 } from 'cornerstone-math';
|
||||
|
||||
type Attributes = Record<string, unknown>;
|
||||
type Image = {
|
||||
StudyInstanceUID?: string;
|
||||
getData(): {
|
||||
metadata: {
|
||||
ImagePositionPatient: number[];
|
||||
ImageOrientationPatient: number[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
images: Image[];
|
||||
uid: string;
|
||||
instances: Image[];
|
||||
instance?: Image;
|
||||
StudyInstanceUID?: string;
|
||||
|
||||
constructor(images: Image[]) {
|
||||
if (!Array.isArray(images)) {
|
||||
throw new Error('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
|
||||
});
|
||||
|
||||
this.instances = images;
|
||||
this.instance = images[0];
|
||||
this.StudyInstanceUID = this.instance?.StudyInstanceUID;
|
||||
}
|
||||
|
||||
load: () => Promise<void>;
|
||||
|
||||
getUID(): string {
|
||||
return this.uid;
|
||||
}
|
||||
|
||||
setAttribute(attribute: string, value: unknown): void {
|
||||
this[attribute] = value;
|
||||
}
|
||||
|
||||
getAttribute(attribute: string): unknown {
|
||||
return this[attribute];
|
||||
}
|
||||
|
||||
setAttributes(attributes: Attributes): void {
|
||||
if (typeof attributes === 'object' && attributes !== null) {
|
||||
for (const [attribute, value] of Object.entries(attributes)) {
|
||||
this[attribute] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNumImages = (): number => this.images.length;
|
||||
|
||||
getImage(index: number): Image {
|
||||
return this.images[index];
|
||||
}
|
||||
|
||||
sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] {
|
||||
return this.images.sort(sortingCallback);
|
||||
}
|
||||
|
||||
sortByImagePositionPatient(): void {
|
||||
const images = this.images;
|
||||
const referenceImagePositionPatient = _getImagePositionPatient(images[0]);
|
||||
|
||||
const refIppVec = new Vector3(
|
||||
referenceImagePositionPatient[0],
|
||||
referenceImagePositionPatient[1],
|
||||
referenceImagePositionPatient[2]
|
||||
);
|
||||
|
||||
const ImageOrientationPatient = _getImageOrientationPatient(images[0]);
|
||||
|
||||
const scanAxisNormal = new Vector3(
|
||||
ImageOrientationPatient[0],
|
||||
ImageOrientationPatient[1],
|
||||
ImageOrientationPatient[2]
|
||||
).cross(
|
||||
new Vector3(
|
||||
ImageOrientationPatient[3],
|
||||
ImageOrientationPatient[4],
|
||||
ImageOrientationPatient[5]
|
||||
)
|
||||
);
|
||||
|
||||
const distanceImagePairs = images.map(function (image: Image) {
|
||||
const ippVec = new Vector3(..._getImagePositionPatient(image));
|
||||
const positionVector = refIppVec.clone().sub(ippVec);
|
||||
const distance = positionVector.dot(scanAxisNormal);
|
||||
|
||||
return {
|
||||
distance,
|
||||
image,
|
||||
};
|
||||
});
|
||||
|
||||
distanceImagePairs.sort(function (a, b) {
|
||||
return b.distance - a.distance;
|
||||
});
|
||||
|
||||
const sortedImages = distanceImagePairs.map(a => a.image);
|
||||
|
||||
images.sort(function (a, b) {
|
||||
return sortedImages.indexOf(a) - sortedImages.indexOf(b);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _getImagePositionPatient(image) {
|
||||
return image.getData().metadata.ImagePositionPatient;
|
||||
}
|
||||
|
||||
function _getImageOrientationPatient(image) {
|
||||
return image.getData().metadata.ImageOrientationPatient;
|
||||
}
|
||||
|
||||
export default ImageSet;
|
||||
589
platform/core/src/classes/MetadataProvider.ts
Normal file
589
platform/core/src/classes/MetadataProvider.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import queryString from 'query-string';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import { imageIdToURI } from '../utils';
|
||||
import getPixelSpacingInformation from '../utils/metadataProvider/getPixelSpacingInformation';
|
||||
import DicomMetadataStore from '../services/DicomMetadataStore';
|
||||
import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData';
|
||||
import toNumber from '../utils/toNumber';
|
||||
import combineFrameInstance from '../utils/combineFrameInstance';
|
||||
|
||||
class MetadataProvider {
|
||||
private readonly studies: Map<string, any> = new Map();
|
||||
private readonly imageURIToUIDs: Map<string, any> = new Map();
|
||||
private readonly imageUIDsByImageId: Map<string, any> = new Map();
|
||||
// Can be used to store custom metadata for a specific type.
|
||||
// For instance, the scaling metadata for PET can be stored here
|
||||
// as type "scalingModule"
|
||||
private readonly customMetadata: Map<string, any> = new Map();
|
||||
|
||||
addImageIdToUIDs(imageId, uids) {
|
||||
// This method is a fallback for when you don't have WADO-URI or WADO-RS.
|
||||
// You can add instances fetched by any method by calling addInstance, and hook an imageId to point at it here.
|
||||
// An example would be dicom hosted at some random site.
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
this.imageURIToUIDs.set(imageURI, uids);
|
||||
this.imageUIDsByImageId.set(imageId, uids);
|
||||
}
|
||||
|
||||
addCustomMetadata(imageId, type, metadata) {
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
if (!this.customMetadata.has(type)) {
|
||||
this.customMetadata.set(type, {});
|
||||
}
|
||||
|
||||
this.customMetadata.get(type)[imageURI] = metadata;
|
||||
}
|
||||
|
||||
_getInstance(imageId) {
|
||||
const uids = this.getUIDsFromImageID(imageId);
|
||||
|
||||
if (!uids) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, frameNumber } = uids;
|
||||
|
||||
const instance = DicomMetadataStore.getInstance(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID
|
||||
);
|
||||
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (frameNumber && combineFrameInstance(frameNumber, instance)) || instance;
|
||||
}
|
||||
|
||||
get(query, imageId, options = { fallback: false }) {
|
||||
if (Array.isArray(imageId)) {
|
||||
return;
|
||||
}
|
||||
const instance = this._getInstance(imageId);
|
||||
|
||||
if (query === INSTANCE) {
|
||||
return instance;
|
||||
}
|
||||
|
||||
// check inside custom metadata
|
||||
if (this.customMetadata.has(query)) {
|
||||
const customMetadata = this.customMetadata.get(query);
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
if (customMetadata[imageURI]) {
|
||||
return customMetadata[imageURI];
|
||||
}
|
||||
}
|
||||
|
||||
return this.getTagFromInstance(query, instance, options);
|
||||
}
|
||||
|
||||
getTag(query, imageId, options) {
|
||||
return this.get(query, imageId, options);
|
||||
}
|
||||
|
||||
getInstance(imageId) {
|
||||
return this.get(INSTANCE, imageId);
|
||||
}
|
||||
|
||||
getTagFromInstance(naturalizedTagOrWADOImageLoaderTag, instance, options = { fallback: false }) {
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If its a naturalized dcmjs tag present on the instance, return.
|
||||
if (instance[naturalizedTagOrWADOImageLoaderTag]) {
|
||||
return instance[naturalizedTagOrWADOImageLoaderTag];
|
||||
}
|
||||
|
||||
// Maybe its a legacy dicomImageLoader tag then:
|
||||
return this._getCornerstoneDICOMImageLoaderTag(naturalizedTagOrWADOImageLoaderTag, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new handler for the given tag. The handler will be provided an
|
||||
* instance object that it can read values from.
|
||||
*/
|
||||
public addHandler(wadoImageLoaderTag: string, handler) {
|
||||
WADO_IMAGE_LOADER[wadoImageLoaderTag] = handler;
|
||||
}
|
||||
|
||||
_getCornerstoneDICOMImageLoaderTag(wadoImageLoaderTag, instance) {
|
||||
let metadata = WADO_IMAGE_LOADER[wadoImageLoaderTag]?.(instance);
|
||||
if (metadata) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
switch (wadoImageLoaderTag) {
|
||||
case WADO_IMAGE_LOADER_TAGS.GENERAL_SERIES_MODULE:
|
||||
const { SeriesDate, SeriesTime } = instance;
|
||||
|
||||
let seriesDate;
|
||||
let seriesTime;
|
||||
|
||||
if (SeriesDate) {
|
||||
seriesDate = dicomParser.parseDA(SeriesDate);
|
||||
}
|
||||
|
||||
if (SeriesTime) {
|
||||
seriesTime = dicomParser.parseTM(SeriesTime);
|
||||
}
|
||||
|
||||
metadata = {
|
||||
modality: instance.Modality,
|
||||
seriesInstanceUID: instance.SeriesInstanceUID,
|
||||
seriesNumber: toNumber(instance.SeriesNumber),
|
||||
studyInstanceUID: instance.StudyInstanceUID,
|
||||
seriesDate,
|
||||
seriesTime,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
|
||||
metadata = {
|
||||
patientAge: toNumber(instance.PatientAge),
|
||||
patientSize: toNumber(instance.PatientSize),
|
||||
patientWeight: toNumber(instance.PatientWeight),
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PATIENT_DEMOGRAPHIC_MODULE:
|
||||
metadata = {
|
||||
patientSex: instance.PatientSex,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE:
|
||||
metadata = {
|
||||
samplesPerPixel: toNumber(instance.SamplesPerPixel),
|
||||
photometricInterpretation: instance.PhotometricInterpretation,
|
||||
rows: toNumber(instance.Rows),
|
||||
columns: toNumber(instance.Columns),
|
||||
bitsAllocated: toNumber(instance.BitsAllocated),
|
||||
bitsStored: toNumber(instance.BitsStored),
|
||||
highBit: toNumber(instance.HighBit),
|
||||
pixelRepresentation: toNumber(instance.PixelRepresentation),
|
||||
planarConfiguration: toNumber(instance.PlanarConfiguration),
|
||||
pixelAspectRatio: toNumber(instance.PixelAspectRatio),
|
||||
smallestPixelValue: toNumber(instance.SmallestPixelValue),
|
||||
largestPixelValue: toNumber(instance.LargestPixelValue),
|
||||
redPaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.RedPaletteColorLookupTableDescriptor
|
||||
),
|
||||
greenPaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.GreenPaletteColorLookupTableDescriptor
|
||||
),
|
||||
bluePaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.BluePaletteColorLookupTableDescriptor
|
||||
),
|
||||
redPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance,
|
||||
'RedPaletteColorLookupTableData',
|
||||
'RedPaletteColorLookupTableDescriptor'
|
||||
),
|
||||
greenPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance,
|
||||
'GreenPaletteColorLookupTableData',
|
||||
'GreenPaletteColorLookupTableDescriptor'
|
||||
),
|
||||
bluePaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance,
|
||||
'BluePaletteColorLookupTableData',
|
||||
'BluePaletteColorLookupTableDescriptor'
|
||||
),
|
||||
};
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.VOI_LUT_MODULE:
|
||||
const { WindowCenter, WindowWidth, VOILUTFunction } = instance;
|
||||
if (WindowCenter === undefined || WindowWidth === undefined) {
|
||||
return;
|
||||
}
|
||||
const windowCenter = Array.isArray(WindowCenter) ? WindowCenter : [WindowCenter];
|
||||
const windowWidth = Array.isArray(WindowWidth) ? WindowWidth : [WindowWidth];
|
||||
|
||||
metadata = {
|
||||
windowCenter: toNumber(windowCenter),
|
||||
windowWidth: toNumber(windowWidth),
|
||||
voiLUTFunction: VOILUTFunction,
|
||||
};
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.MODALITY_LUT_MODULE:
|
||||
const { RescaleIntercept, RescaleSlope } = instance;
|
||||
if (RescaleIntercept === undefined || RescaleSlope === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
metadata = {
|
||||
rescaleIntercept: toNumber(instance.RescaleIntercept),
|
||||
rescaleSlope: toNumber(instance.RescaleSlope),
|
||||
rescaleType: instance.RescaleType,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.SOP_COMMON_MODULE:
|
||||
metadata = {
|
||||
sopClassUID: instance.SOPClassUID,
|
||||
sopInstanceUID: instance.SOPInstanceUID,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PET_IMAGE_MODULE:
|
||||
metadata = {
|
||||
frameReferenceTime: instance.FrameReferenceTime,
|
||||
actualFrameDuration: instance.ActualFrameDuration,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PET_ISOTOPE_MODULE:
|
||||
const { RadiopharmaceuticalInformationSequence } = instance;
|
||||
|
||||
if (RadiopharmaceuticalInformationSequence) {
|
||||
const RadiopharmaceuticalInformation = Array.isArray(
|
||||
RadiopharmaceuticalInformationSequence
|
||||
)
|
||||
? RadiopharmaceuticalInformationSequence[0]
|
||||
: RadiopharmaceuticalInformationSequence;
|
||||
|
||||
const { RadiopharmaceuticalStartTime, RadionuclideTotalDose, RadionuclideHalfLife } =
|
||||
RadiopharmaceuticalInformation;
|
||||
|
||||
const radiopharmaceuticalInfo = {
|
||||
radiopharmaceuticalStartTime: dicomParser.parseTM(RadiopharmaceuticalStartTime),
|
||||
radionuclideTotalDose: RadionuclideTotalDose,
|
||||
radionuclideHalfLife: RadionuclideHalfLife,
|
||||
};
|
||||
metadata = {
|
||||
radiopharmaceuticalInfo,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.OVERLAY_PLANE_MODULE:
|
||||
const overlays = [];
|
||||
|
||||
for (let overlayGroup = 0x00; overlayGroup <= 0x1e; overlayGroup += 0x02) {
|
||||
let groupStr = `60${overlayGroup.toString(16)}`;
|
||||
|
||||
if (groupStr.length === 3) {
|
||||
groupStr = `600${overlayGroup.toString(16)}`;
|
||||
}
|
||||
|
||||
const OverlayDataTag = `${groupStr}3000`;
|
||||
const OverlayData = instance[OverlayDataTag];
|
||||
|
||||
if (!OverlayData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const OverlayRowsTag = `${groupStr}0010`;
|
||||
const OverlayColumnsTag = `${groupStr}0011`;
|
||||
const OverlayType = `${groupStr}0040`;
|
||||
const OverlayOriginTag = `${groupStr}0050`;
|
||||
const OverlayDescriptionTag = `${groupStr}0022`;
|
||||
const OverlayLabelTag = `${groupStr}1500`;
|
||||
const ROIAreaTag = `${groupStr}1301`;
|
||||
const ROIMeanTag = `${groupStr}1302`;
|
||||
const ROIStandardDeviationTag = `${groupStr}1303`;
|
||||
const OverlayOrigin = instance[OverlayOriginTag];
|
||||
|
||||
let rows = 0;
|
||||
if (instance[OverlayRowsTag] instanceof Array) {
|
||||
// The DICOM VR for overlay rows is US (unsigned short).
|
||||
const rowsInt16Array = new Uint16Array(instance[OverlayRowsTag][0]);
|
||||
rows = rowsInt16Array[0];
|
||||
} else {
|
||||
rows = instance[OverlayRowsTag];
|
||||
}
|
||||
|
||||
let columns = 0;
|
||||
if (instance[OverlayColumnsTag] instanceof Array) {
|
||||
// The DICOM VR for overlay columns is US (unsigned short).
|
||||
const columnsInt16Array = new Uint16Array(instance[OverlayColumnsTag][0]);
|
||||
columns = columnsInt16Array[0];
|
||||
} else {
|
||||
columns = instance[OverlayColumnsTag];
|
||||
}
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if (OverlayOrigin.length === 1) {
|
||||
// The DICOM VR for overlay origin is SS (signed short) with a multiplicity of 2.
|
||||
const originInt16Array = new Int16Array(OverlayOrigin[0]);
|
||||
x = originInt16Array[0];
|
||||
y = originInt16Array[1];
|
||||
} else {
|
||||
x = OverlayOrigin[0];
|
||||
y = OverlayOrigin[1];
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
rows: rows,
|
||||
columns: columns,
|
||||
type: instance[OverlayType],
|
||||
x,
|
||||
y,
|
||||
pixelData: OverlayData,
|
||||
description: instance[OverlayDescriptionTag],
|
||||
label: instance[OverlayLabelTag],
|
||||
roiArea: instance[ROIAreaTag],
|
||||
roiMean: instance[ROIMeanTag],
|
||||
roiStandardDeviation: instance[ROIStandardDeviationTag],
|
||||
};
|
||||
|
||||
overlays.push(overlay);
|
||||
}
|
||||
|
||||
metadata = {
|
||||
overlays,
|
||||
};
|
||||
|
||||
break;
|
||||
|
||||
case WADO_IMAGE_LOADER_TAGS.PATIENT_MODULE:
|
||||
const { PatientName } = instance;
|
||||
|
||||
let patientName;
|
||||
if (PatientName) {
|
||||
patientName = PatientName.Alphabetic;
|
||||
}
|
||||
|
||||
metadata = {
|
||||
patientName,
|
||||
patientId: instance.PatientID,
|
||||
};
|
||||
|
||||
break;
|
||||
|
||||
case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE:
|
||||
metadata = {
|
||||
sopInstanceUID: instance.SOPInstanceUID,
|
||||
instanceNumber: toNumber(instance.InstanceNumber),
|
||||
lossyImageCompression: instance.LossyImageCompression,
|
||||
lossyImageCompressionRatio: instance.LossyImageCompressionRatio,
|
||||
lossyImageCompressionMethod: instance.LossyImageCompressionMethod,
|
||||
};
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.GENERAL_STUDY_MODULE:
|
||||
metadata = {
|
||||
studyDescription: instance.StudyDescription,
|
||||
studyDate: instance.StudyDate,
|
||||
studyTime: instance.StudyTime,
|
||||
accessionNumber: instance.AccessionNumber,
|
||||
};
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.CINE_MODULE:
|
||||
metadata = {
|
||||
frameTime: instance.FrameTime,
|
||||
numberOfFrames: instance.NumberOfFrames ? Number(instance.NumberOfFrames) : 1,
|
||||
};
|
||||
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PER_SERIES_MODULE:
|
||||
metadata = {
|
||||
correctedImage: instance.CorrectedImage,
|
||||
units: instance.Units,
|
||||
decayCorrection: instance.DecayCorrection,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.CALIBRATION_MODULE:
|
||||
// map the DICOM tags to the cornerstone tags since cornerstone tags
|
||||
// are camelCase and instance tags are all caps
|
||||
metadata = {
|
||||
sequenceOfUltrasoundRegions: instance.SequenceOfUltrasoundRegions?.map(region => {
|
||||
return {
|
||||
regionSpatialFormat: region.RegionSpatialFormat,
|
||||
regionDataType: region.RegionDataType,
|
||||
regionFlags: region.RegionFlags,
|
||||
regionLocationMinX0: region.RegionLocationMinX0,
|
||||
regionLocationMinY0: region.RegionLocationMinY0,
|
||||
regionLocationMaxX1: region.RegionLocationMaxX1,
|
||||
regionLocationMaxY1: region.RegionLocationMaxY1,
|
||||
referencePixelX0: region.ReferencePixelX0,
|
||||
referencePixelY0: region.ReferencePixelY0,
|
||||
referencePixelPhysicalValueX: region.ReferencePixelPhysicalValueX,
|
||||
referencePixelPhysicalValueY: region.ReferencePixelPhysicalValueY,
|
||||
physicalUnitsXDirection: region.PhysicalUnitsXDirection,
|
||||
physicalUnitsYDirection: region.PhysicalUnitsYDirection,
|
||||
physicalDeltaX: region.PhysicalDeltaX,
|
||||
physicalDeltaY: region.PhysicalDeltaY,
|
||||
};
|
||||
}),
|
||||
};
|
||||
break;
|
||||
|
||||
/**
|
||||
* Below are the tags and not the modules since they are not really
|
||||
* consistent with the modules above
|
||||
*/
|
||||
case 'temporalPositionIdentifier':
|
||||
metadata = {
|
||||
temporalPositionIdentifier: instance.TemporalPositionIdentifier,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the frameNumber information, depending on the url style
|
||||
* wadors /frames/1
|
||||
* wadouri &frame=1
|
||||
* @param {*} imageId
|
||||
* @returns
|
||||
*/
|
||||
getFrameInformationFromURL(imageId) {
|
||||
function getInformationFromURL(informationString, separator) {
|
||||
let result = '';
|
||||
const splittedStr = imageId.split(informationString)[1];
|
||||
if (splittedStr.includes(separator)) {
|
||||
result = splittedStr.split(separator)[0];
|
||||
} else {
|
||||
result = splittedStr;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (imageId.includes('/frames')) {
|
||||
return getInformationFromURL('/frames', '/');
|
||||
}
|
||||
if (imageId.includes('&frame=')) {
|
||||
return getInformationFromURL('&frame=', '&');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getUIDsFromImageID(imageId) {
|
||||
if (!imageId) {
|
||||
throw new Error('MetadataProvider::Empty imageId');
|
||||
}
|
||||
// TODO: adding csiv here is not really correct. Probably need to use
|
||||
// metadataProvider.addImageIdToUIDs(imageId, {
|
||||
// StudyInstanceUID,
|
||||
// SeriesInstanceUID,
|
||||
// SOPInstanceUID,
|
||||
// })
|
||||
// somewhere else
|
||||
|
||||
const cachedUIDs = this.imageUIDsByImageId.get(imageId);
|
||||
if (cachedUIDs) {
|
||||
return cachedUIDs;
|
||||
}
|
||||
|
||||
if (imageId.startsWith('wadors:')) {
|
||||
const strippedImageId = imageId.split('/studies/')[1];
|
||||
const splitImageId = strippedImageId.split('/');
|
||||
|
||||
return {
|
||||
StudyInstanceUID: splitImageId[0], // Note: splitImageId[1] === 'series'
|
||||
SeriesInstanceUID: splitImageId[2], // Note: splitImageId[3] === 'instances'
|
||||
SOPInstanceUID: splitImageId[4],
|
||||
frameNumber: splitImageId[6],
|
||||
};
|
||||
} else if (imageId.includes('?requestType=WADO')) {
|
||||
const qs = queryString.parse(imageId);
|
||||
|
||||
return {
|
||||
StudyInstanceUID: qs.studyUID,
|
||||
SeriesInstanceUID: qs.seriesUID,
|
||||
SOPInstanceUID: qs.objectUID,
|
||||
frameNumber: qs.frameNumber,
|
||||
};
|
||||
}
|
||||
|
||||
// Maybe its a non-standard imageId
|
||||
// check if the imageId starts with http:// or https:// using regex
|
||||
// Todo: handle non http imageIds
|
||||
let imageURI;
|
||||
const urlRegex = /^(http|https|dicomfile):\/\//;
|
||||
if (urlRegex.test(imageId)) {
|
||||
imageURI = imageId;
|
||||
} else {
|
||||
imageURI = imageIdToURI(imageId);
|
||||
}
|
||||
|
||||
// remove &frame=number from imageId
|
||||
imageURI = imageURI.split('&frame=')[0];
|
||||
|
||||
const uids = this.imageURIToUIDs.get(imageURI);
|
||||
const frameNumber = this.getFrameInformationFromURL(imageId) || '1';
|
||||
|
||||
if (uids && frameNumber !== undefined) {
|
||||
return { ...uids, frameNumber };
|
||||
}
|
||||
return uids;
|
||||
}
|
||||
}
|
||||
|
||||
const metadataProvider = new MetadataProvider();
|
||||
|
||||
export default metadataProvider;
|
||||
|
||||
const WADO_IMAGE_LOADER = {
|
||||
imagePlaneModule: instance => {
|
||||
const { ImageOrientationPatient } = instance;
|
||||
|
||||
// Fallback for DX images.
|
||||
// TODO: We should use the rest of the results of this function
|
||||
// to update the UI somehow
|
||||
const { PixelSpacing } = getPixelSpacingInformation(instance);
|
||||
|
||||
let rowPixelSpacing;
|
||||
let columnPixelSpacing;
|
||||
|
||||
let rowCosines;
|
||||
let columnCosines;
|
||||
|
||||
if (PixelSpacing) {
|
||||
rowPixelSpacing = PixelSpacing[0];
|
||||
columnPixelSpacing = PixelSpacing[1];
|
||||
}
|
||||
|
||||
if (ImageOrientationPatient) {
|
||||
rowCosines = ImageOrientationPatient.slice(0, 3);
|
||||
columnCosines = ImageOrientationPatient.slice(3, 6);
|
||||
}
|
||||
|
||||
return {
|
||||
frameOfReferenceUID: instance.FrameOfReferenceUID,
|
||||
rows: toNumber(instance.Rows),
|
||||
columns: toNumber(instance.Columns),
|
||||
imageOrientationPatient: toNumber(ImageOrientationPatient) || [0, 1, 0, 0, 0, -1],
|
||||
rowCosines: toNumber(rowCosines || [0, 1, 0]),
|
||||
isDefaultValueSetForRowCosine: toNumber(rowCosines) ? false : true,
|
||||
columnCosines: toNumber(columnCosines || [0, 0, -1]),
|
||||
isDefaultValueSetForColumnCosine: toNumber(columnCosines) ? false : true,
|
||||
imagePositionPatient: toNumber(instance.ImagePositionPatient || [0, 0, 0]),
|
||||
sliceThickness: toNumber(instance.SliceThickness),
|
||||
sliceLocation: toNumber(instance.SliceLocation),
|
||||
pixelSpacing: toNumber(PixelSpacing || 1),
|
||||
rowPixelSpacing: rowPixelSpacing ? toNumber(rowPixelSpacing) : null,
|
||||
columnPixelSpacing: columnPixelSpacing ? toNumber(columnPixelSpacing) : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const WADO_IMAGE_LOADER_TAGS = {
|
||||
// dicomImageLoader specific
|
||||
GENERAL_SERIES_MODULE: 'generalSeriesModule',
|
||||
PATIENT_STUDY_MODULE: 'patientStudyModule',
|
||||
IMAGE_PIXEL_MODULE: 'imagePixelModule',
|
||||
VOI_LUT_MODULE: 'voiLutModule',
|
||||
MODALITY_LUT_MODULE: 'modalityLutModule',
|
||||
SOP_COMMON_MODULE: 'sopCommonModule',
|
||||
PET_IMAGE_MODULE: 'petImageModule',
|
||||
PET_ISOTOPE_MODULE: 'petIsotopeModule',
|
||||
PER_SERIES_MODULE: 'petSeriesModule',
|
||||
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
|
||||
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
|
||||
|
||||
// react-cornerstone-viewport specific
|
||||
PATIENT_MODULE: 'patientModule',
|
||||
GENERAL_IMAGE_MODULE: 'generalImageModule',
|
||||
GENERAL_STUDY_MODULE: 'generalStudyModule',
|
||||
CINE_MODULE: 'cineModule',
|
||||
CALIBRATION_MODULE: 'calibrationModule',
|
||||
};
|
||||
|
||||
const INSTANCE = 'instance';
|
||||
15
platform/core/src/classes/index.js
Normal file
15
platform/core/src/classes/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import CommandsManager from './CommandsManager';
|
||||
import HotkeysManager from './HotkeysManager';
|
||||
import ImageSet from './ImageSet';
|
||||
import MetadataProvider from './MetadataProvider';
|
||||
|
||||
export { MetadataProvider, CommandsManager, HotkeysManager, ImageSet };
|
||||
|
||||
const classes = {
|
||||
MetadataProvider,
|
||||
CommandsManager,
|
||||
HotkeysManager,
|
||||
ImageSet,
|
||||
};
|
||||
|
||||
export default classes;
|
||||
203
platform/core/src/defaults/hotkeyBindings.js
Normal file
203
platform/core/src/defaults/hotkeyBindings.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import windowLevelPresets from './windowLevelPresets';
|
||||
|
||||
/*
|
||||
* Supported Keys: https://craig.is/killing/mice
|
||||
*/
|
||||
const bindings = [
|
||||
{
|
||||
commandName: 'setToolActive',
|
||||
commandOptions: { toolName: 'Zoom' },
|
||||
label: 'Zoom',
|
||||
keys: ['z'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'scaleUpViewport',
|
||||
label: 'Zoom In',
|
||||
keys: ['+'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'scaleDownViewport',
|
||||
label: 'Zoom Out',
|
||||
keys: ['-'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'fitViewportToWindow',
|
||||
label: 'Zoom to Fit',
|
||||
keys: ['='],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'rotateViewportCW',
|
||||
label: 'Rotate Right',
|
||||
keys: ['r'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'rotateViewportCCW',
|
||||
label: 'Rotate Left',
|
||||
keys: ['l'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'flipViewportHorizontal',
|
||||
label: 'Flip Horizontally',
|
||||
keys: ['h'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'flipViewportVertical',
|
||||
label: 'Flip Vertically',
|
||||
keys: ['v'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'toggleCine',
|
||||
label: 'Cine',
|
||||
keys: ['c'],
|
||||
},
|
||||
{
|
||||
commandName: 'invertViewport',
|
||||
label: 'Invert',
|
||||
keys: ['i'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'incrementActiveViewport',
|
||||
label: 'Next Image Viewport',
|
||||
keys: ['right'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'decrementActiveViewport',
|
||||
label: 'Previous Image Viewport',
|
||||
keys: ['left'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'updateViewportDisplaySet',
|
||||
commandOptions: {
|
||||
direction: -1,
|
||||
},
|
||||
label: 'Previous Series',
|
||||
keys: ['pageup'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'updateViewportDisplaySet',
|
||||
commandOptions: {
|
||||
direction: 1,
|
||||
},
|
||||
label: 'Next Series',
|
||||
keys: ['pagedown'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'nextStage',
|
||||
context: 'DEFAULT',
|
||||
label: 'Next Stage',
|
||||
keys: ['.'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'previousStage',
|
||||
context: 'DEFAULT',
|
||||
label: 'Previous Stage',
|
||||
keys: [','],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'nextImage',
|
||||
label: 'Next Image',
|
||||
keys: ['down'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'previousImage',
|
||||
label: 'Previous Image',
|
||||
keys: ['up'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'firstImage',
|
||||
label: 'First Image',
|
||||
keys: ['home'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'lastImage',
|
||||
label: 'Last Image',
|
||||
keys: ['end'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'resetViewport',
|
||||
label: 'Reset',
|
||||
keys: ['space'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'cancelMeasurement',
|
||||
label: 'Cancel Cornerstone Measurement',
|
||||
keys: ['esc'],
|
||||
},
|
||||
{
|
||||
commandName: 'setWindowLevel',
|
||||
commandOptions: windowLevelPresets[1],
|
||||
label: 'W/L Preset 1',
|
||||
keys: ['1'],
|
||||
},
|
||||
{
|
||||
commandName: 'setWindowLevel',
|
||||
commandOptions: windowLevelPresets[2],
|
||||
label: 'W/L Preset 2',
|
||||
keys: ['2'],
|
||||
},
|
||||
{
|
||||
commandName: 'setWindowLevel',
|
||||
commandOptions: windowLevelPresets[3],
|
||||
label: 'W/L Preset 3',
|
||||
keys: ['3'],
|
||||
},
|
||||
{
|
||||
commandName: 'setWindowLevel',
|
||||
commandOptions: windowLevelPresets[4],
|
||||
label: 'W/L Preset 4',
|
||||
keys: ['4'],
|
||||
},
|
||||
{
|
||||
commandName: 'setWindowLevel',
|
||||
commandOptions: windowLevelPresets[5],
|
||||
label: 'W/L Preset 5',
|
||||
keys: ['5'],
|
||||
},
|
||||
// These don't exist, so don't try applying them....
|
||||
// {
|
||||
// commandName: 'setWindowLevel',
|
||||
// commandOptions: windowLevelPresets[6],
|
||||
// label: 'W/L Preset 6',
|
||||
// keys: ['6'],
|
||||
// },
|
||||
// {
|
||||
// commandName: 'setWindowLevel',
|
||||
// commandOptions: windowLevelPresets[7],
|
||||
// label: 'W/L Preset 7',
|
||||
// keys: ['7'],
|
||||
// },
|
||||
// {
|
||||
// commandName: 'setWindowLevel',
|
||||
// commandOptions: windowLevelPresets[8],
|
||||
// label: 'W/L Preset 8',
|
||||
// keys: ['8'],
|
||||
// },
|
||||
// {
|
||||
// commandName: 'setWindowLevel',
|
||||
// commandOptions: windowLevelPresets[9],
|
||||
// label: 'W/L Preset 9',
|
||||
// keys: ['9'],
|
||||
// },
|
||||
];
|
||||
|
||||
export default bindings;
|
||||
4
platform/core/src/defaults/index.js
Normal file
4
platform/core/src/defaults/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import hotkeyBindings from './hotkeyBindings';
|
||||
import windowLevelPresets from './windowLevelPresets';
|
||||
export { hotkeyBindings, windowLevelPresets };
|
||||
export default { hotkeyBindings, windowLevelPresets };
|
||||
12
platform/core/src/defaults/windowLevelPresets.js
Normal file
12
platform/core/src/defaults/windowLevelPresets.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
1: { description: 'Soft tissue', window: '400', level: '40' },
|
||||
2: { description: 'Lung', window: '1500', level: '-600' },
|
||||
3: { description: 'Liver', window: '150', level: '90' },
|
||||
4: { description: 'Bone', window: '2500', level: '480' },
|
||||
5: { description: 'Brain', window: '80', level: '40' },
|
||||
6: { description: 'Trest', window: '1', level: '1' },
|
||||
7: { description: 'Empty1', window: 'Empty1', level: 'Empty1' },
|
||||
8: { description: 'Empty2', window: 'Empty2', level: 'Empty2' },
|
||||
9: { description: 'Empty3', window: 'Empty3', level: 'Empty3' },
|
||||
10: { description: 'Empty4', window: 'Empty4', level: 'Empty4' },
|
||||
};
|
||||
24
platform/core/src/enums/TimingEnum.ts
Normal file
24
platform/core/src/enums/TimingEnum.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export enum TimingEnum {
|
||||
// The time from when the users selects a study until the study metadata
|
||||
// is loaded (and the display sets are ready)
|
||||
STUDY_TO_DISPLAY_SETS = 'studyToDisplaySetsLoaded',
|
||||
|
||||
// The time from when the user selects a study until any viewport renders
|
||||
STUDY_TO_FIRST_IMAGE = 'studyToFirstImage',
|
||||
|
||||
// The time from when display sets are loaded until any viewport renders
|
||||
// an image.
|
||||
DISPLAY_SETS_TO_FIRST_IMAGE = 'displaySetsToFirstImage',
|
||||
|
||||
// The time from when display sets are loaded until all viewports have images
|
||||
DISPLAY_SETS_TO_ALL_IMAGES = 'displaySetsToAllImages',
|
||||
|
||||
// The time from when the user hits search until the worklist is displayed
|
||||
SEARCH_TO_LIST = 'searchToList',
|
||||
|
||||
// The time from when the html script first starts being evaluated (before
|
||||
// any other scripts or CSS is loaded), until the time that the first image
|
||||
// is viewed for viewer endpoints, or the time that the first search for studies
|
||||
// completes.
|
||||
SCRIPT_TO_VIEW = 'scriptToView',
|
||||
}
|
||||
3
platform/core/src/enums/index.ts
Normal file
3
platform/core/src/enums/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { TimingEnum } from './TimingEnum';
|
||||
|
||||
export { TimingEnum };
|
||||
6
platform/core/src/errorHandler.js
Normal file
6
platform/core/src/errorHandler.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// These should be overridden by the implementation
|
||||
const errorHandler = {
|
||||
getHTTPErrorHandler: () => null,
|
||||
};
|
||||
|
||||
export default errorHandler;
|
||||
280
platform/core/src/extensions/ExtensionManager.test.js
Normal file
280
platform/core/src/extensions/ExtensionManager.test.js
Normal file
@@ -0,0 +1,280 @@
|
||||
import ExtensionManager from './ExtensionManager';
|
||||
import MODULE_TYPES from './MODULE_TYPES';
|
||||
import log from './../log.js';
|
||||
|
||||
jest.mock('./../log.js');
|
||||
|
||||
describe('ExtensionManager.ts', () => {
|
||||
let extensionManager, commandsManager, servicesManager, appConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
commandsManager = {
|
||||
createContext: jest.fn(),
|
||||
getContext: jest.fn(),
|
||||
registerCommand: jest.fn(),
|
||||
};
|
||||
servicesManager = {
|
||||
registerService: jest.fn(),
|
||||
services: {
|
||||
// Required for DataSource Module initiation
|
||||
UserAuthenticationService: jest.fn(),
|
||||
HangingProtocolService: {
|
||||
addProtocol: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
appConfig = {
|
||||
testing: true,
|
||||
};
|
||||
extensionManager = new ExtensionManager({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
});
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates a module namespace for each module type', () => {
|
||||
const moduleKeys = Object.keys(extensionManager.modules);
|
||||
const moduleTypeValues = Object.values(MODULE_TYPES);
|
||||
|
||||
expect(moduleKeys.sort()).toEqual(moduleTypeValues.sort());
|
||||
});
|
||||
|
||||
describe('registerExtensions()', () => {
|
||||
it('calls registerExtension() for each extension', async () => {
|
||||
extensionManager.registerExtension = jest.fn();
|
||||
|
||||
// SUT
|
||||
const fakeExtensions = [{ one: '1' }, { two: '2' }, { three: '3 ' }];
|
||||
await extensionManager.registerExtensions(fakeExtensions);
|
||||
|
||||
// Assert
|
||||
expect(extensionManager.registerExtension.mock.calls.length).toBe(3);
|
||||
});
|
||||
|
||||
it('calls registerExtension() for each extension passing its configuration if tuple', async () => {
|
||||
const fakeConfiguration = { testing: true };
|
||||
extensionManager.registerExtension = jest.fn();
|
||||
|
||||
// SUT
|
||||
const fakeExtensions = [{ one: '1' }, [{ two: '2' }, fakeConfiguration], { three: '3 ' }];
|
||||
await extensionManager.registerExtensions(fakeExtensions);
|
||||
|
||||
// Assert
|
||||
expect(extensionManager.registerExtension.mock.calls[1][1]).toEqual(fakeConfiguration);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerExtension()', () => {
|
||||
it('calls preRegistration() for extension', () => {
|
||||
// SUT
|
||||
const fakeExtension = { id: '1', preRegistration: jest.fn() };
|
||||
extensionManager.registerExtension(fakeExtension);
|
||||
|
||||
// Assert
|
||||
expect(fakeExtension.preRegistration.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('calls preRegistration() passing dependencies and extension configuration to extension', () => {
|
||||
const extensionConfiguration = { config: 'Some configuration' };
|
||||
|
||||
// SUT
|
||||
const extension = { id: '1', preRegistration: jest.fn() };
|
||||
extensionManager.registerExtension(extension, extensionConfiguration);
|
||||
|
||||
// Assert
|
||||
expect(extension.preRegistration.mock.calls[0][0]).toEqual({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
configuration: extensionConfiguration,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a warning if the extension is null or undefined', async () => {
|
||||
const undefinedExtension = undefined;
|
||||
const nullExtension = null;
|
||||
|
||||
await expect(extensionManager.registerExtension(undefinedExtension)).rejects.toThrow(
|
||||
new Error('Attempting to register a null/undefined extension.')
|
||||
);
|
||||
|
||||
await expect(extensionManager.registerExtension(nullExtension)).rejects.toThrow(
|
||||
new Error('Attempting to register a null/undefined extension.')
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a warning if the extension does not have an id', async () => {
|
||||
const extensionWithoutId = {};
|
||||
|
||||
await expect(extensionManager.registerExtension(extensionWithoutId)).rejects.toThrow(
|
||||
new Error('Extension ID not set')
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks which extensions have been registered', () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
};
|
||||
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
expect(extensionManager.registeredExtensionIds).toContain(extension.id);
|
||||
});
|
||||
|
||||
it('logs a warning if the extension has an id that has already been registered', () => {
|
||||
const extension = { id: 'hello-world' };
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
// SUT
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs a warning if a defined module returns null or undefined', () => {
|
||||
const extensionWithBadModule = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: () => {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
extensionManager.registerExtension(extensionWithBadModule);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
expect(log.warn.mock.calls[0][0]).toContain('Null or undefined returned when registering');
|
||||
});
|
||||
|
||||
it('logs an error if an exception is thrown while retrieving a module', async () => {
|
||||
const extensionWithBadModule = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: () => {
|
||||
throw new Error('Hello World');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(extensionManager.registerExtension(extensionWithBadModule)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('successfully passes dependencies to each module along with extension configuration', () => {
|
||||
const extensionConfiguration = { testing: true };
|
||||
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: jest.fn(),
|
||||
getSopClassHandlerModule: jest.fn(),
|
||||
getPanelModule: jest.fn(),
|
||||
getToolbarModule: jest.fn(),
|
||||
getCommandsModule: jest.fn(),
|
||||
};
|
||||
|
||||
extensionManager.registerExtension(extension, extensionConfiguration);
|
||||
|
||||
Object.keys(extension).forEach(module => {
|
||||
if (typeof extension[module] === 'function') {
|
||||
expect(extension[module].mock.calls[0][0]).toEqual({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
hotkeysManager: undefined,
|
||||
appConfig,
|
||||
configuration: extensionConfiguration,
|
||||
extensionManager,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('successfully registers a module for each module type', async () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getSopClassHandlerModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getPanelModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getToolbarModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getCommandsModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getLayoutTemplateModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getDataSourcesModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getHangingProtocolModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getContextModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getUtilityModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getCustomizationModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
getStateSyncModule: () => {
|
||||
return [{ name: 'test' }];
|
||||
},
|
||||
};
|
||||
|
||||
await extensionManager.registerExtension(extension);
|
||||
|
||||
// Registers 1 module per module type
|
||||
Object.keys(extensionManager.modules).forEach(moduleType => {
|
||||
const modulesForType = extensionManager.modules[moduleType];
|
||||
console.log('moduleType', moduleType);
|
||||
expect(modulesForType.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls commandsManager.registerCommand for each commandsModule command definition', () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getCommandsModule: () => {
|
||||
return {
|
||||
definitions: {
|
||||
exampleDefinition: {
|
||||
commandFn: () => {},
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// SUT
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
expect(commandsManager.registerCommand.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('logs a warning if the commandsModule contains no command definitions', () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getCommandsModule: () => {
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
// SUT
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
expect(log.warn.mock.calls[0][0]).toContain(
|
||||
'Commands Module contains no command definitions'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
626
platform/core/src/extensions/ExtensionManager.ts
Normal file
626
platform/core/src/extensions/ExtensionManager.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import MODULE_TYPES from './MODULE_TYPES';
|
||||
import log from '../log';
|
||||
import { PubSubService, ServiceProvidersManager } from '../services';
|
||||
import { HotkeysManager, CommandsManager } from '../classes';
|
||||
import type { DataSourceDefinition } from '../types';
|
||||
import type AppTypes from '../types/AppTypes';
|
||||
|
||||
/**
|
||||
* This is the arguments given to create the extension.
|
||||
*/
|
||||
export interface ExtensionConstructor {
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
serviceProvidersManager: ServiceProvidersManager;
|
||||
commandsManager: CommandsManager;
|
||||
hotkeysManager: HotkeysManager;
|
||||
appConfig: AppTypes.Config;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration of an extension.
|
||||
* This uses type as the extension manager only knows that the configuration
|
||||
* is an object of some sort, and doesn't know anything else about it.
|
||||
*/
|
||||
export type ExtensionConfiguration = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The parameters passed to the extension.
|
||||
*/
|
||||
export interface ExtensionParams extends ExtensionConstructor {
|
||||
extensionManager: ExtensionManager;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
serviceProvidersManager: ServiceProvidersManager;
|
||||
configuration?: ExtensionConfiguration;
|
||||
peerImport: (moduleId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of an actual extension instance.
|
||||
* This is an interface as it declares possible calls, but extensions can
|
||||
* have more values than this.
|
||||
*/
|
||||
export interface Extension {
|
||||
id: string;
|
||||
preRegistration?: (p: ExtensionParams) => Promise<void> | void;
|
||||
getHangingProtocolModule?: (p: ExtensionParams) => unknown;
|
||||
getCommandsModule?: (p: ExtensionParams) => CommandsModule;
|
||||
getViewportModule?: (p: ExtensionParams) => unknown;
|
||||
getUtilityModule?: (p: ExtensionParams) => unknown;
|
||||
getCustomizationModule?: (p: ExtensionParams) => unknown;
|
||||
getSopClassHandlerModule?: (p: ExtensionParams) => unknown;
|
||||
getToolbarModule?: (p: ExtensionParams) => unknown;
|
||||
getPanelModule?: (p: ExtensionParams) => unknown;
|
||||
onModeEnter?: (p: AppTypes) => void;
|
||||
onModeExit?: (p: AppTypes) => void;
|
||||
}
|
||||
|
||||
export type ExtensionRegister = {
|
||||
id: string;
|
||||
create: (p: ExtensionParams) => Extension;
|
||||
};
|
||||
|
||||
export type CommandsModule = {
|
||||
actions: Record<string, unknown>;
|
||||
definitions: Record<string, unknown>;
|
||||
defaultContext?: string;
|
||||
};
|
||||
|
||||
export default class ExtensionManager extends PubSubService {
|
||||
public static readonly EVENTS = {
|
||||
ACTIVE_DATA_SOURCE_CHANGED: 'event::activedatasourcechanged',
|
||||
};
|
||||
|
||||
public static readonly MODULE_TYPES = MODULE_TYPES;
|
||||
|
||||
private _commandsManager: CommandsManager;
|
||||
private _servicesManager: AppTypes.ServicesManager;
|
||||
private _hotkeysManager: HotkeysManager;
|
||||
private _serviceProvidersManager: ServiceProvidersManager;
|
||||
private modulesMap: Record<string, unknown>;
|
||||
private modules: Record<string, any[]>;
|
||||
private registeredExtensionIds: string[];
|
||||
private moduleTypeNames: string[];
|
||||
private _appConfig: any;
|
||||
private _extensionLifeCycleHooks: {
|
||||
onModeEnter: Record<string, any>;
|
||||
onModeExit: Record<string, any>;
|
||||
};
|
||||
private dataSourceMap: Record<string, any>;
|
||||
private dataSourceDefs: Record<string, any>;
|
||||
private defaultDataSourceName: string;
|
||||
private activeDataSource: string;
|
||||
private peerImport: (moduleId) => Promise<any>;
|
||||
|
||||
constructor({
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
serviceProvidersManager,
|
||||
hotkeysManager,
|
||||
appConfig = {},
|
||||
}: ExtensionConstructor) {
|
||||
super(ExtensionManager.EVENTS);
|
||||
this.modules = {};
|
||||
this.registeredExtensionIds = [];
|
||||
this.moduleTypeNames = Object.values(MODULE_TYPES);
|
||||
//
|
||||
this._commandsManager = commandsManager;
|
||||
this._servicesManager = servicesManager;
|
||||
this._serviceProvidersManager = serviceProvidersManager;
|
||||
this._hotkeysManager = hotkeysManager;
|
||||
this._appConfig = appConfig;
|
||||
|
||||
this.modulesMap = {};
|
||||
this.moduleTypeNames.forEach(moduleType => {
|
||||
this.modules[moduleType] = [];
|
||||
});
|
||||
this._extensionLifeCycleHooks = { onModeEnter: {}, onModeExit: {} };
|
||||
this.dataSourceMap = {};
|
||||
this.dataSourceDefs = {};
|
||||
this.defaultDataSourceName = appConfig.defaultDataSourceName;
|
||||
this.activeDataSource = appConfig.defaultDataSourceName;
|
||||
this.peerImport = appConfig.peerImport;
|
||||
}
|
||||
|
||||
public setActiveDataSource(dataSource: string): void {
|
||||
if (this.activeDataSource === dataSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeDataSource = dataSource;
|
||||
|
||||
this._broadcastEvent(
|
||||
ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
|
||||
this.dataSourceDefs[this.activeDataSource]
|
||||
);
|
||||
}
|
||||
|
||||
public getRegisteredExtensionIds() {
|
||||
return [...this.registeredExtensionIds];
|
||||
}
|
||||
|
||||
private getUniqueServicesList(servicesManager: AppTypes.ServicesManager) {
|
||||
// Make sure only one service instance is returned because almost all services are
|
||||
// registered with different keys (eg: StudyPrefetcherService and studyPrefetcherService)
|
||||
return Array.from(new Set(Object.values(servicesManager.services)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls all the services and extension on mode enters.
|
||||
* The service onModeEnter is called first
|
||||
* Then registered extensions onModeEnter is called
|
||||
* This is supposed to setup the extension for a standard entry.
|
||||
*/
|
||||
public onModeEnter(): void {
|
||||
const {
|
||||
registeredExtensionIds,
|
||||
_servicesManager,
|
||||
_commandsManager,
|
||||
_hotkeysManager,
|
||||
_extensionLifeCycleHooks,
|
||||
} = this;
|
||||
const services = this.getUniqueServicesList(_servicesManager);
|
||||
|
||||
// The onModeEnter of the service must occur BEFORE the extension
|
||||
// onModeEnter in order to reset the state to a standard state
|
||||
// before the extension restores and cached data.
|
||||
for (const service of services) {
|
||||
service?.onModeEnter?.();
|
||||
}
|
||||
|
||||
registeredExtensionIds.forEach(extensionId => {
|
||||
const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId];
|
||||
|
||||
if (typeof onModeEnter === 'function') {
|
||||
onModeEnter({
|
||||
servicesManager: _servicesManager,
|
||||
commandsManager: _commandsManager,
|
||||
hotkeysManager: _hotkeysManager,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onModeExit(): void {
|
||||
const { registeredExtensionIds, _servicesManager, _commandsManager, _extensionLifeCycleHooks } =
|
||||
this;
|
||||
const services = this.getUniqueServicesList(_servicesManager);
|
||||
|
||||
registeredExtensionIds.forEach(extensionId => {
|
||||
const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId];
|
||||
|
||||
if (typeof onModeExit === 'function') {
|
||||
onModeExit({
|
||||
servicesManager: _servicesManager,
|
||||
commandsManager: _commandsManager,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The service onModeExit calls must occur after the extension ones
|
||||
// so that extension ones can store/restore data.
|
||||
for (const service of services) {
|
||||
try {
|
||||
service?.onModeExit?.();
|
||||
} catch (e) {
|
||||
console.warn('onModeExit caught', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of extensions, or an array of arrays that contains extension
|
||||
* configuration pairs.
|
||||
*
|
||||
* @param {Object[]} extensions - Array of extensions
|
||||
*/
|
||||
public registerExtensions = async (
|
||||
extensions: (ExtensionRegister | [ExtensionRegister, ExtensionConfiguration])[],
|
||||
dataSources: unknown[] = []
|
||||
): Promise<void> => {
|
||||
// Todo: we ideally should be able to run registrations in parallel
|
||||
// but currently since some extensions need to be registered before
|
||||
// others, we need to run them sequentially. We need a postInit hook
|
||||
// to avoid this sequential async registration
|
||||
for (let i = 0; i < extensions.length; i++) {
|
||||
const extension = extensions[i];
|
||||
const hasConfiguration = Array.isArray(extension);
|
||||
try {
|
||||
if (hasConfiguration) {
|
||||
// Important: for some reason in the line below the type
|
||||
// of extension is not recognized as [ExtensionRegister,
|
||||
// ExtensionConfiguration] by babel DON"T CHANGE IT
|
||||
// Same for the for loop above don't use
|
||||
// for (const extension of extensions)
|
||||
const ohifExtension = extension[0];
|
||||
const configuration = extension[1];
|
||||
await this.registerExtension(ohifExtension, configuration, dataSources);
|
||||
} else {
|
||||
await this.registerExtension(extension, {}, dataSources);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* TODO: Id Management: SopClassHandlers currently refer to viewport module by id; setting the extension id as viewport module id is a workaround for now
|
||||
* @param {Object} extension
|
||||
* @param {Object} configuration
|
||||
*/
|
||||
public registerExtension = async (
|
||||
extension: ExtensionRegister,
|
||||
configuration = {},
|
||||
dataSources = []
|
||||
): Promise<void> => {
|
||||
if (!extension) {
|
||||
throw new Error('Attempting to register a null/undefined extension.');
|
||||
}
|
||||
|
||||
const extensionId = extension.id;
|
||||
|
||||
if (!extensionId) {
|
||||
// Note: Mode framework cannot function without IDs.
|
||||
log.warn(extension);
|
||||
throw new Error(`Extension ID not set`);
|
||||
}
|
||||
|
||||
if (this.registeredExtensionIds.includes(extensionId)) {
|
||||
log.warn(
|
||||
`Extension ID ${extensionId} has already been registered. Exiting before duplicating modules.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// preRegistrationHook
|
||||
if (extension.preRegistration) {
|
||||
await extension.preRegistration({
|
||||
servicesManager: this._servicesManager,
|
||||
serviceProvidersManager: this._serviceProvidersManager,
|
||||
commandsManager: this._commandsManager,
|
||||
hotkeysManager: this._hotkeysManager,
|
||||
extensionManager: this,
|
||||
appConfig: this._appConfig,
|
||||
configuration,
|
||||
});
|
||||
}
|
||||
|
||||
if (extension.onModeEnter) {
|
||||
this._extensionLifeCycleHooks.onModeEnter[extensionId] = extension.onModeEnter;
|
||||
}
|
||||
|
||||
if (extension.onModeExit) {
|
||||
this._extensionLifeCycleHooks.onModeExit[extensionId] = extension.onModeExit;
|
||||
}
|
||||
|
||||
// Register Modules
|
||||
this.moduleTypeNames.forEach(moduleType => {
|
||||
const extensionModule = this._getExtensionModule(
|
||||
moduleType,
|
||||
extension,
|
||||
extensionId,
|
||||
configuration
|
||||
);
|
||||
|
||||
if (!extensionModule) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (moduleType) {
|
||||
case MODULE_TYPES.COMMANDS:
|
||||
this._initCommandsModule(extensionModule);
|
||||
break;
|
||||
|
||||
case MODULE_TYPES.DATA_SOURCE:
|
||||
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
|
||||
break;
|
||||
|
||||
case MODULE_TYPES.HANGING_PROTOCOL:
|
||||
this._initHangingProtocolsModule(extensionModule, extensionId);
|
||||
break;
|
||||
|
||||
case MODULE_TYPES.PANEL:
|
||||
this._initPanelModule(extensionModule, extensionId);
|
||||
break;
|
||||
|
||||
case MODULE_TYPES.TOOLBAR:
|
||||
this._initToolbarModule(extensionModule, extensionId);
|
||||
break;
|
||||
|
||||
case MODULE_TYPES.VIEWPORT:
|
||||
case MODULE_TYPES.SOP_CLASS_HANDLER:
|
||||
case MODULE_TYPES.CONTEXT:
|
||||
case MODULE_TYPES.LAYOUT_TEMPLATE:
|
||||
case MODULE_TYPES.CUSTOMIZATION:
|
||||
case MODULE_TYPES.STATE_SYNC:
|
||||
case MODULE_TYPES.UTILITY:
|
||||
this.processExtensionModule(extensionModule, extensionId, moduleType);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Module type invalid: ${moduleType}`);
|
||||
}
|
||||
|
||||
this.modules[moduleType].push({
|
||||
extensionId,
|
||||
module: extensionModule,
|
||||
});
|
||||
});
|
||||
|
||||
// Track extension registration
|
||||
this.registeredExtensionIds.push(extensionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the module entry associated with the given string entry
|
||||
* @param stringEntry - The string entry to retrieve the module entry for which is
|
||||
* in the format of `${extensionId}.${moduleType}.${moduleName}`
|
||||
* @returns The module entry associated with the given string entry.
|
||||
*/
|
||||
getModuleEntry = stringEntry => {
|
||||
return this.modulesMap[stringEntry];
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all modules of a given type for all registered extensions.
|
||||
*
|
||||
* @param moduleType - The type of modules to retrieve.
|
||||
* @returns An array of modules of the specified type.
|
||||
*/
|
||||
getModulesByType = (moduleType: string) => {
|
||||
return this.modules[moduleType];
|
||||
};
|
||||
|
||||
getDataSources = dataSourceName => {
|
||||
if (dataSourceName === undefined) {
|
||||
// Default to the activeDataSource
|
||||
dataSourceName = this.activeDataSource;
|
||||
}
|
||||
|
||||
// Note: this currently uses the data source name, which feels weird...
|
||||
return this.dataSourceMap[dataSourceName];
|
||||
};
|
||||
|
||||
getActiveDataSource = () => {
|
||||
return this.dataSourceMap[this.activeDataSource];
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the data source definition for the given data source name.
|
||||
* If no data source name is provided, the active data source definition is
|
||||
* returned.
|
||||
* @param dataSourceName the data source name
|
||||
* @returns the data source definition
|
||||
*/
|
||||
getDataSourceDefinition = dataSourceName => {
|
||||
if (dataSourceName === undefined) {
|
||||
// Default to the activeDataSource
|
||||
dataSourceName = this.activeDataSource;
|
||||
}
|
||||
|
||||
return this.dataSourceDefs[dataSourceName];
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the data source definition for the active data source.
|
||||
*/
|
||||
getActiveDataSourceDefinition = () => {
|
||||
return this.getDataSourceDefinition(this.activeDataSource);
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} moduleType
|
||||
* @param {Object} extension
|
||||
* @param {string} extensionId - Used for logging warnings
|
||||
*/
|
||||
_getExtensionModule = (moduleType, extension, extensionId, configuration) => {
|
||||
const getModuleFnName = 'get' + _capitalizeFirstCharacter(moduleType);
|
||||
const getModuleFn = extension[getModuleFnName];
|
||||
|
||||
if (!getModuleFn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const extensionModule = extension[getModuleFnName]({
|
||||
appConfig: this._appConfig,
|
||||
commandsManager: this._commandsManager,
|
||||
servicesManager: this._servicesManager,
|
||||
hotkeysManager: this._hotkeysManager,
|
||||
extensionManager: this,
|
||||
configuration,
|
||||
});
|
||||
|
||||
if (!extensionModule) {
|
||||
log.warn(
|
||||
`Null or undefined returned when registering the ${getModuleFnName} module for the ${extensionId} extension`
|
||||
);
|
||||
}
|
||||
|
||||
return extensionModule;
|
||||
} catch (ex) {
|
||||
console.error(ex);
|
||||
throw new Error(
|
||||
`Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
_initHangingProtocolsModule = (extensionModule, extensionId) => {
|
||||
const { hangingProtocolService } = this._servicesManager.services;
|
||||
extensionModule.forEach(({ name, protocol }) => {
|
||||
if (protocol) {
|
||||
// Only auto-register if protocol specified, otherwise let mode register
|
||||
hangingProtocolService.addProtocol(name, protocol);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
_initPanelModule = (extensionModule, extensionId) => {
|
||||
this.processExtensionModule(extensionModule, extensionId, MODULE_TYPES.PANEL);
|
||||
};
|
||||
|
||||
_initToolbarModule = (extensionModule, extensionId) => {
|
||||
// check if the toolbar module has a handler function for evaluation of
|
||||
// the toolbar button state
|
||||
const { toolbarService } = this._servicesManager.services;
|
||||
extensionModule.forEach(toolbarButton => {
|
||||
if (toolbarButton.evaluate) {
|
||||
toolbarService.registerEvaluateFunction(toolbarButton.name, toolbarButton.evaluate);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an extension module.
|
||||
* @param extensionModule - The extension module to process.
|
||||
* @param extensionId - The ID of the extension.
|
||||
* @param moduleType - The type of the module.
|
||||
*/
|
||||
private processExtensionModule(extensionModule, extensionId: string, moduleType: string) {
|
||||
extensionModule.forEach(element => {
|
||||
if (!element.name) {
|
||||
throw new Error(`Extension ID ${extensionId} module ${moduleType} element has no name`);
|
||||
}
|
||||
const id = `${extensionId}.${moduleType}.${element.name}`;
|
||||
element.id = id;
|
||||
this.modulesMap[id] = element;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given data source and optionally sets it as the active data source.
|
||||
* The method does this by first creating the data source.
|
||||
* @param dataSourceDef the data source definition to be added
|
||||
* @param activate flag to indicate if the added data source should be set to the active data source
|
||||
*/
|
||||
addDataSource(dataSourceDef: DataSourceDefinition, options = { activate: false }) {
|
||||
const existingDataSource = this.getDataSources(dataSourceDef.sourceName);
|
||||
if (existingDataSource?.[0]) {
|
||||
// The data source already exists and cannot be added.
|
||||
return;
|
||||
}
|
||||
|
||||
this._createDataSourceInstance(dataSourceDef);
|
||||
|
||||
if (options.activate) {
|
||||
this.setActiveDataSource(dataSourceDef.sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration of the given data source name. It first creates a new data source with
|
||||
* the existing definition and the new configuration passed in.
|
||||
* @param dataSourceName the name of the data source to update
|
||||
* @param dataSourceConfiguration the new configuration to update the data source with
|
||||
*/
|
||||
updateDataSourceConfiguration(dataSourceName: string, dataSourceConfiguration: any) {
|
||||
const existingDataSource = this.getDataSources(dataSourceName);
|
||||
if (!existingDataSource?.[0]) {
|
||||
// Cannot update a non existent data source.
|
||||
return;
|
||||
}
|
||||
|
||||
const dataSourceDef = this.dataSourceDefs[dataSourceName];
|
||||
// Update the configuration.
|
||||
dataSourceDef.configuration = dataSourceConfiguration;
|
||||
this._createDataSourceInstance(dataSourceDef);
|
||||
|
||||
if (this.activeDataSource === dataSourceName) {
|
||||
// When the active data source is changed/set, fire an event to indicate that its configuration has changed.
|
||||
this._broadcastEvent(ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED, dataSourceDef);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data source instance from the given definition. The definition is
|
||||
* added to dataSourceDefs and the created instance is added to dataSourceMap.
|
||||
* @param dataSourceDef
|
||||
* @returns
|
||||
*/
|
||||
_createDataSourceInstance(dataSourceDef: DataSourceDefinition) {
|
||||
const module = this.getModuleEntry(dataSourceDef.namespace);
|
||||
|
||||
if (!module) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dataSourceDefs[dataSourceDef.sourceName] = dataSourceDef;
|
||||
|
||||
const dataSourceInstance = module.createDataSource(
|
||||
dataSourceDef.configuration,
|
||||
this._servicesManager,
|
||||
this
|
||||
);
|
||||
|
||||
this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance];
|
||||
}
|
||||
|
||||
_initDataSourcesModule(
|
||||
extensionModule,
|
||||
extensionId,
|
||||
dataSources: Array<DataSourceDefinition> = []
|
||||
): void {
|
||||
extensionModule.forEach(element => {
|
||||
this.modulesMap[`${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`] = element;
|
||||
});
|
||||
|
||||
extensionModule.forEach(element => {
|
||||
const namespace = `${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`;
|
||||
|
||||
dataSources.forEach(dataSource => {
|
||||
if (dataSource.namespace === namespace) {
|
||||
this.addDataSource(dataSource);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} commandDefinitions
|
||||
*/
|
||||
_initCommandsModule = extensionModule => {
|
||||
let { definitions, defaultContext } = extensionModule;
|
||||
if (!definitions || Object.keys(definitions).length === 0) {
|
||||
log.warn('Commands Module contains no command definitions');
|
||||
return;
|
||||
}
|
||||
|
||||
defaultContext = defaultContext || 'VIEWER';
|
||||
|
||||
if (!this._commandsManager.getContext(defaultContext)) {
|
||||
this._commandsManager.createContext(defaultContext);
|
||||
}
|
||||
|
||||
Object.keys(definitions).forEach(commandName => {
|
||||
const commandDefinition = definitions[commandName];
|
||||
const commandHasContextThatDoesNotExist =
|
||||
commandDefinition.context && !this._commandsManager.getContext(commandDefinition.context);
|
||||
|
||||
if (commandHasContextThatDoesNotExist) {
|
||||
this._commandsManager.createContext(commandDefinition.context);
|
||||
}
|
||||
|
||||
this._commandsManager.registerCommand(
|
||||
commandDefinition.context || defaultContext,
|
||||
commandName,
|
||||
commandDefinition
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
public get appConfig() {
|
||||
return this._appConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} lower
|
||||
*/
|
||||
function _capitalizeFirstCharacter(lower) {
|
||||
return lower.charAt(0).toUpperCase() + lower.substring(1);
|
||||
}
|
||||
14
platform/core/src/extensions/MODULE_TYPES.js
Normal file
14
platform/core/src/extensions/MODULE_TYPES.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export default {
|
||||
COMMANDS: 'commandsModule',
|
||||
CUSTOMIZATION: 'customizationModule',
|
||||
STATE_SYNC: 'stateSyncModule',
|
||||
DATA_SOURCE: 'dataSourcesModule',
|
||||
PANEL: 'panelModule',
|
||||
SOP_CLASS_HANDLER: 'sopClassHandlerModule',
|
||||
TOOLBAR: 'toolbarModule',
|
||||
VIEWPORT: 'viewportModule',
|
||||
CONTEXT: 'contextModule',
|
||||
LAYOUT_TEMPLATE: 'layoutTemplateModule',
|
||||
HANGING_PROTOCOL: 'hangingProtocolModule',
|
||||
UTILITY: 'utilityModule',
|
||||
};
|
||||
11
platform/core/src/extensions/index.js
Normal file
11
platform/core/src/extensions/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ExtensionManager from './ExtensionManager';
|
||||
import MODULE_TYPES from './MODULE_TYPES';
|
||||
|
||||
const DEFAULT_EXPORTS = {
|
||||
ExtensionManager,
|
||||
MODULE_TYPES,
|
||||
};
|
||||
|
||||
export default DEFAULT_EXPORTS;
|
||||
|
||||
export { ExtensionManager, MODULE_TYPES };
|
||||
60
platform/core/src/hooks/useActiveViewportDisplaySets.ts
Normal file
60
platform/core/src/hooks/useActiveViewportDisplaySets.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { DisplaySet } from '../types';
|
||||
|
||||
/**
|
||||
* Hook that listens for changes in the active viewport and its display sets.
|
||||
* It returns the display sets associated with the active viewport.
|
||||
*
|
||||
* @param servicesManager - Services manager instance
|
||||
* @returns Array of display sets for the active viewport
|
||||
*/
|
||||
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
|
||||
const [displaySets, setDisplaySets] = useState<DisplaySet[]>([]);
|
||||
const { displaySetService, viewportGridService } = servicesManager.services;
|
||||
|
||||
// Move this function outside useEffect and memoize it
|
||||
const getDisplaySetsForViewport = useCallback(
|
||||
(viewportId: string) => {
|
||||
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId) || [];
|
||||
return displaySetUIDs.map(uid => displaySetService.getDisplaySetByUID(uid)).filter(Boolean);
|
||||
},
|
||||
[displaySetService, viewportGridService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial state
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
setDisplaySets(getDisplaySetsForViewport(viewportId));
|
||||
|
||||
const handleViewportChange = ({ viewportId }) => {
|
||||
setDisplaySets(getDisplaySetsForViewport(viewportId));
|
||||
};
|
||||
|
||||
const handleGridStateChange = ({ state }) => {
|
||||
const activeViewportId = state.activeViewportId;
|
||||
if (activeViewportId) {
|
||||
setDisplaySets(getDisplaySetsForViewport(activeViewportId));
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to viewport changes
|
||||
const subscriptions = [
|
||||
viewportGridService.subscribe(
|
||||
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
|
||||
handleViewportChange
|
||||
),
|
||||
viewportGridService.subscribe(
|
||||
viewportGridService.EVENTS.GRID_STATE_CHANGED,
|
||||
handleGridStateChange
|
||||
),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
};
|
||||
}, [viewportGridService, getDisplaySetsForViewport]); // Only depend on stable references
|
||||
|
||||
return displaySets;
|
||||
};
|
||||
|
||||
export default useActiveViewportDisplaySets;
|
||||
58
platform/core/src/hooks/useToolbar.tsx
Normal file
58
platform/core/src/hooks/useToolbar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export function useToolbar({ servicesManager, buttonSection = 'primary' }: withAppTypes) {
|
||||
const { toolbarService, viewportGridService } = servicesManager.services;
|
||||
const { EVENTS } = toolbarService;
|
||||
|
||||
const [toolbarButtons, setToolbarButtons] = useState(
|
||||
toolbarService.getButtonSection(buttonSection)
|
||||
);
|
||||
|
||||
// Callback function for handling toolbar interactions
|
||||
const onInteraction = useCallback(
|
||||
args => {
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
const refreshProps = {
|
||||
viewportId,
|
||||
};
|
||||
toolbarService.recordInteraction(args, {
|
||||
refreshProps,
|
||||
});
|
||||
},
|
||||
[toolbarService, viewportGridService]
|
||||
);
|
||||
|
||||
// Effect to handle toolbar modification events
|
||||
useEffect(() => {
|
||||
const handleToolbarModified = () => {
|
||||
setToolbarButtons(toolbarService.getButtonSection(buttonSection));
|
||||
};
|
||||
|
||||
const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => {
|
||||
return toolbarService.subscribe(event, handleToolbarModified);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subs.forEach(sub => sub.unsubscribe());
|
||||
};
|
||||
}, [toolbarService]);
|
||||
|
||||
// Effect to handle active viewportId change event
|
||||
useEffect(() => {
|
||||
const events = [
|
||||
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
|
||||
viewportGridService.EVENTS.VIEWPORTS_READY,
|
||||
];
|
||||
|
||||
const subscriptions = events.map(event => {
|
||||
return viewportGridService.subscribe(event, ({ viewportId }) => {
|
||||
viewportId = viewportId || viewportGridService.getActiveViewportId();
|
||||
toolbarService.refreshToolbarState({ viewportId });
|
||||
});
|
||||
});
|
||||
|
||||
return () => subscriptions.forEach(sub => sub.unsubscribe());
|
||||
}, [viewportGridService, toolbarService]);
|
||||
|
||||
return { toolbarButtons, onInteraction };
|
||||
}
|
||||
9
platform/core/src/ie.js
Normal file
9
platform/core/src/ie.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import writeScript from './lib/writeScript';
|
||||
|
||||
// Check if browser is IE and add the polyfill scripts
|
||||
if (navigator && /MSIE \d|Trident.*rv:/.test(navigator.userAgent)) {
|
||||
window.onload = () => {
|
||||
// Fix SVG+USE issues by calling the SVG polyfill
|
||||
writeScript('svgxuse.min.js');
|
||||
};
|
||||
}
|
||||
142
platform/core/src/index.ts
Normal file
142
platform/core/src/index.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { ExtensionManager, MODULE_TYPES } from './extensions';
|
||||
import { ServiceProvidersManager, ServicesManager } from './services';
|
||||
import classes, { CommandsManager, HotkeysManager } from './classes';
|
||||
|
||||
import DICOMWeb from './DICOMWeb';
|
||||
import errorHandler from './errorHandler.js';
|
||||
import log from './log.js';
|
||||
import object from './object.js';
|
||||
import string from './string.js';
|
||||
import user from './user.js';
|
||||
import utils from './utils';
|
||||
import defaults from './defaults';
|
||||
import * as Types from './types';
|
||||
import * as Enums from './enums';
|
||||
import { useToolbar } from './hooks/useToolbar';
|
||||
import {
|
||||
CineService,
|
||||
UIDialogService,
|
||||
UIModalService,
|
||||
UINotificationService,
|
||||
UIViewportDialogService,
|
||||
//
|
||||
DicomMetadataStore,
|
||||
DisplaySetService,
|
||||
ToolbarService,
|
||||
MeasurementService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
pubSubServiceInterface,
|
||||
PubSubService,
|
||||
UserAuthenticationService,
|
||||
CustomizationService,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
} from './services';
|
||||
|
||||
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
|
||||
|
||||
import IWebApiDataSource from './DataSources/IWebApiDataSource';
|
||||
import useActiveViewportDisplaySets from './hooks/useActiveViewportDisplaySets';
|
||||
|
||||
const hotkeys = {
|
||||
...utils.hotkeys,
|
||||
defaults: { hotkeyBindings: defaults.hotkeyBindings },
|
||||
};
|
||||
|
||||
const OHIF = {
|
||||
MODULE_TYPES,
|
||||
//
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
HotkeysManager,
|
||||
ServicesManager,
|
||||
ServiceProvidersManager,
|
||||
//
|
||||
defaults,
|
||||
utils,
|
||||
hotkeys,
|
||||
classes,
|
||||
string,
|
||||
user,
|
||||
errorHandler,
|
||||
object,
|
||||
log,
|
||||
DICOMWeb,
|
||||
viewer: {},
|
||||
//
|
||||
CineService,
|
||||
CustomizationService,
|
||||
UIDialogService,
|
||||
UIModalService,
|
||||
UINotificationService,
|
||||
UIViewportDialogService,
|
||||
DisplaySetService,
|
||||
MeasurementService,
|
||||
ToolbarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
UserAuthenticationService,
|
||||
IWebApiDataSource,
|
||||
DicomMetadataStore,
|
||||
pubSubServiceInterface,
|
||||
PubSubService,
|
||||
PanelService,
|
||||
useToolbar,
|
||||
useActiveViewportDisplaySets,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
};
|
||||
|
||||
export {
|
||||
MODULE_TYPES,
|
||||
//
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
HotkeysManager,
|
||||
ServicesManager,
|
||||
ServiceProvidersManager,
|
||||
//
|
||||
defaults,
|
||||
utils,
|
||||
hotkeys,
|
||||
classes,
|
||||
string,
|
||||
user,
|
||||
errorHandler,
|
||||
object,
|
||||
log,
|
||||
DICOMWeb,
|
||||
//
|
||||
CineService,
|
||||
CustomizationService,
|
||||
UIDialogService,
|
||||
UIModalService,
|
||||
UINotificationService,
|
||||
UIViewportDialogService,
|
||||
DisplaySetService,
|
||||
DisplaySetMessage,
|
||||
DisplaySetMessageList,
|
||||
MeasurementService,
|
||||
ToolbarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
UserAuthenticationService,
|
||||
IWebApiDataSource,
|
||||
DicomMetadataStore,
|
||||
pubSubServiceInterface,
|
||||
PubSubService,
|
||||
Enums,
|
||||
PanelService,
|
||||
WorkflowStepsService,
|
||||
StudyPrefetcherService,
|
||||
useToolbar,
|
||||
useActiveViewportDisplaySets,
|
||||
};
|
||||
|
||||
export { OHIF };
|
||||
|
||||
export type { Types };
|
||||
|
||||
export default OHIF;
|
||||
28
platform/core/src/log.js
Normal file
28
platform/core/src/log.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const log = {
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.log,
|
||||
trace: console.trace,
|
||||
debug: console.debug,
|
||||
time: key => {
|
||||
log.timingKeys[key] = true;
|
||||
console.time(key);
|
||||
},
|
||||
timeEnd: key => {
|
||||
if (!log.timingKeys[key]) {
|
||||
return;
|
||||
}
|
||||
log.timingKeys[key] = false;
|
||||
console.timeEnd(key);
|
||||
},
|
||||
// Store the timing keys to allow knowing whether or not to log events
|
||||
timingKeys: {
|
||||
// script time values are added during the index.html initial load,
|
||||
// before log (this file) is loaded, and the log
|
||||
// can't depend on the enums, so for this case recreate the string.
|
||||
// See TimingEnum for details
|
||||
scriptToView: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
24
platform/core/src/measurements/tools/polygonRoi.js
Normal file
24
platform/core/src/measurements/tools/polygonRoi.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const displayFunction = data => {
|
||||
let meanValue = '';
|
||||
const { cachedStats } = data;
|
||||
if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) {
|
||||
meanValue = cachedStats.mean.toFixed(2) + ' HU';
|
||||
}
|
||||
return meanValue;
|
||||
};
|
||||
|
||||
export const polygonRoi = {
|
||||
id: 'PolygonRoi',
|
||||
name: 'Polygon',
|
||||
toolGroup: 'allTools',
|
||||
cornerstoneToolType: 'PlanarFreehandROITool',
|
||||
options: {
|
||||
measurementTable: {
|
||||
displayFunction,
|
||||
},
|
||||
caseProgress: {
|
||||
include: true,
|
||||
evaluate: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
59
platform/core/src/object.js
Normal file
59
platform/core/src/object.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Transforms a shallow object with keys separated by "." into a nested object
|
||||
function getNestedObject(shallowObject) {
|
||||
const nestedObject = {};
|
||||
for (let key in shallowObject) {
|
||||
if (!shallowObject.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
const value = shallowObject[key];
|
||||
const propertyArray = key.split('.');
|
||||
let currentObject = nestedObject;
|
||||
while (propertyArray.length) {
|
||||
const currentProperty = propertyArray.shift();
|
||||
if (!propertyArray.length) {
|
||||
currentObject[currentProperty] = value;
|
||||
} else {
|
||||
if (!currentObject[currentProperty]) {
|
||||
currentObject[currentProperty] = {};
|
||||
}
|
||||
|
||||
currentObject = currentObject[currentProperty];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nestedObject;
|
||||
}
|
||||
|
||||
// Transforms a nested object into a shallowObject merging its keys with "." character
|
||||
function getShallowObject(nestedObject) {
|
||||
const shallowObject = {};
|
||||
const putValues = (baseKey, nestedObject, resultObject) => {
|
||||
for (let key in nestedObject) {
|
||||
if (!nestedObject.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
let currentKey = baseKey ? `${baseKey}.${key}` : key;
|
||||
const currentValue = nestedObject[key];
|
||||
if (typeof currentValue === 'object') {
|
||||
if (currentValue instanceof Array) {
|
||||
currentKey += '[]';
|
||||
}
|
||||
|
||||
putValues(currentKey, currentValue, resultObject);
|
||||
} else {
|
||||
resultObject[currentKey] = currentValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
putValues('', nestedObject, shallowObject);
|
||||
return shallowObject;
|
||||
}
|
||||
|
||||
const object = {
|
||||
getNestedObject,
|
||||
getShallowObject,
|
||||
};
|
||||
|
||||
export default object;
|
||||
123
platform/core/src/services/CineService/CineService.ts
Normal file
123
platform/core/src/services/CineService/CineService.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
|
||||
class CineService extends PubSubService {
|
||||
public static readonly EVENTS = {
|
||||
CINE_STATE_CHANGED: 'event::cineStateChanged',
|
||||
};
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'cineService',
|
||||
altName: 'CineService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new CineService();
|
||||
},
|
||||
};
|
||||
|
||||
serviceImplementation = {};
|
||||
startedClips = new Map();
|
||||
closedViewports = new Set();
|
||||
|
||||
constructor() {
|
||||
super(CineService.EVENTS);
|
||||
this.serviceImplementation = {};
|
||||
}
|
||||
|
||||
public getState() {
|
||||
return this.serviceImplementation._getState();
|
||||
}
|
||||
|
||||
public setCine({ id, frameRate, isPlaying }) {
|
||||
return this.serviceImplementation._setCine({ id, frameRate, isPlaying });
|
||||
}
|
||||
|
||||
public setIsCineEnabled(isCineEnabled) {
|
||||
this.serviceImplementation._setIsCineEnabled(isCineEnabled);
|
||||
// Todo: for some reason i need to do this setTimeout since the
|
||||
// reducer state does not get updated right away and if we publish the
|
||||
// event and we use the cineService.getState() it will return the old state
|
||||
if (isCineEnabled) {
|
||||
this.closedViewports.forEach(viewportId => {
|
||||
this.clearViewportCineClosed(viewportId);
|
||||
});
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isCineEnabled });
|
||||
});
|
||||
}
|
||||
|
||||
public playClip(element, playClipOptions) {
|
||||
const res = this.serviceImplementation._playClip(element, playClipOptions);
|
||||
|
||||
this.startedClips.set(element, playClipOptions);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: true });
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public stopClip(element, stopClipOptions) {
|
||||
const res = this.serviceImplementation._stopClip(element, stopClipOptions);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: false });
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public onModeExit() {
|
||||
this.setIsCineEnabled(false);
|
||||
this.startedClips.forEach((value, key) => {
|
||||
this.stopClip(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
public getSyncedViewports(viewportId) {
|
||||
return this.serviceImplementation._getSyncedViewports(viewportId);
|
||||
}
|
||||
|
||||
public setViewportCineClosed(viewportId) {
|
||||
this.closedViewports.add(viewportId);
|
||||
}
|
||||
|
||||
public isViewportCineClosed(viewportId) {
|
||||
// Todo: we should move towards per viewport cine closed in next release
|
||||
return this.closedViewports.size > 0;
|
||||
}
|
||||
|
||||
public clearViewportCineClosed(viewportId) {
|
||||
this.closedViewports.delete(viewportId);
|
||||
}
|
||||
|
||||
public setServiceImplementation({
|
||||
getState: getStateImplementation,
|
||||
setCine: setCineImplementation,
|
||||
setIsCineEnabled: setIsCineEnabledImplementation,
|
||||
playClip: playClipImplementation,
|
||||
stopClip: stopClipImplementation,
|
||||
getSyncedViewports: getSyncedViewportsImplementation,
|
||||
}) {
|
||||
if (getSyncedViewportsImplementation) {
|
||||
this.serviceImplementation._getSyncedViewports = getSyncedViewportsImplementation;
|
||||
}
|
||||
|
||||
if (getStateImplementation) {
|
||||
this.serviceImplementation._getState = getStateImplementation;
|
||||
}
|
||||
if (setCineImplementation) {
|
||||
this.serviceImplementation._setCine = setCineImplementation;
|
||||
}
|
||||
if (setIsCineEnabledImplementation) {
|
||||
this.serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
|
||||
}
|
||||
|
||||
if (playClipImplementation) {
|
||||
this.serviceImplementation._playClip = playClipImplementation;
|
||||
}
|
||||
|
||||
if (stopClipImplementation) {
|
||||
this.serviceImplementation._stopClip = stopClipImplementation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CineService;
|
||||
2
platform/core/src/services/CineService/index.ts
Normal file
2
platform/core/src/services/CineService/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import CineService from './CineService';
|
||||
export default CineService;
|
||||
@@ -0,0 +1,313 @@
|
||||
import CustomizationService, { CustomizationType, MergeEnum } from './CustomizationService';
|
||||
import log from '../../log';
|
||||
|
||||
jest.mock('../../log.js', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const extensionManager = {
|
||||
registeredExtensionIds: [],
|
||||
moduleEntries: {},
|
||||
|
||||
getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds,
|
||||
|
||||
getModuleEntry: function (id) {
|
||||
return this.moduleEntries[id];
|
||||
},
|
||||
};
|
||||
|
||||
const commandsManager = {};
|
||||
|
||||
const ohifOverlayItem = {
|
||||
id: 'ohif.overlayItem',
|
||||
content: function (props) {
|
||||
return {
|
||||
label: this.label,
|
||||
value: props[this.attribute],
|
||||
ver: 'default',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const testItem = {
|
||||
id: 'testItem',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
attribute: 'testAttribute',
|
||||
label: 'testItemLabel',
|
||||
};
|
||||
|
||||
describe('CustomizationService.ts', () => {
|
||||
let customizationService;
|
||||
|
||||
let configuration;
|
||||
|
||||
beforeEach(() => {
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
configuration = {};
|
||||
customizationService = new CustomizationService({
|
||||
configuration,
|
||||
commandsManager,
|
||||
});
|
||||
extensionManager.registeredExtensionIds = [];
|
||||
extensionManager.moduleEntries = {};
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('init succeeds', () => {
|
||||
customizationService.init(extensionManager);
|
||||
});
|
||||
|
||||
it('configurationRegistered', () => {
|
||||
configuration.testItem = testItem;
|
||||
customizationService.init(extensionManager);
|
||||
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
|
||||
});
|
||||
|
||||
it('defaultRegistered', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
|
||||
name: 'default',
|
||||
value: [testItem],
|
||||
};
|
||||
customizationService.init(extensionManager);
|
||||
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customizationType', () => {
|
||||
it('inherits type', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
|
||||
name: 'default',
|
||||
value: [ohifOverlayItem],
|
||||
};
|
||||
configuration.testItem = testItem;
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
const item = customizationService.getGlobalCustomization('testItem');
|
||||
|
||||
const props = { testAttribute: 'testAttrValue' };
|
||||
const result = item.content(props);
|
||||
expect(result.label).toBe(testItem.label);
|
||||
expect(result.value).toBe(props.testAttribute);
|
||||
expect(result.ver).toBe('default');
|
||||
});
|
||||
|
||||
it('inline default inherits type', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
|
||||
name: 'default',
|
||||
value: [ohifOverlayItem],
|
||||
};
|
||||
configuration.testItem = testItem;
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
const item = customizationService.getCustomization('testItem2', {
|
||||
id: 'testItem2',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: 'otherLabel',
|
||||
attribute: 'otherAttr',
|
||||
});
|
||||
|
||||
// Customizes the default value, as this is testItem2
|
||||
const props = { otherAttr: 'other attribute value' };
|
||||
const result = item.content(props);
|
||||
expect(result.label).toBe('otherLabel');
|
||||
expect(result.value).toBe(props.otherAttr);
|
||||
expect(result.ver).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode customization', () => {
|
||||
it('onModeEnter can add extensions', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
|
||||
name: 'default',
|
||||
value: [ohifOverlayItem],
|
||||
};
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
expect(customizationService.getModeCustomization('testItem')).toBeUndefined();
|
||||
|
||||
customizationService.addModeCustomizations([testItem]);
|
||||
|
||||
expect(customizationService.getGlobalCustomization('testItem')).toBeUndefined();
|
||||
|
||||
const item = customizationService.getModeCustomization('testItem');
|
||||
expect(item).not.toBeUndefined();
|
||||
|
||||
const props = { testAttribute: 'testAttrValue' };
|
||||
const result = item.content(props);
|
||||
expect(result.label).toBe(testItem.label);
|
||||
expect(result.value).toBe(props.testAttribute);
|
||||
expect(result.ver).toBe('default');
|
||||
});
|
||||
|
||||
it('global customizations override modes', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.global'] = {
|
||||
name: 'default',
|
||||
value: [ohifOverlayItem],
|
||||
};
|
||||
configuration.testItem = testItem;
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
// Add a mode customization that would otherwise fail below
|
||||
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
|
||||
|
||||
const item = customizationService.getModeCustomization('testItem');
|
||||
|
||||
const props = { testAttribute: 'testAttrValue' };
|
||||
const result = item.content(props);
|
||||
expect(result.label).toBe(testItem.label);
|
||||
expect(result.value).toBe(props.testAttribute);
|
||||
});
|
||||
|
||||
it('mode customizations override default', () => {
|
||||
extensionManager.registeredExtensionIds.push('@testExtension');
|
||||
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
|
||||
name: 'default',
|
||||
value: [ohifOverlayItem, testItem],
|
||||
};
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
// Add a mode customization that would otherwise fail below
|
||||
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
|
||||
|
||||
const item = customizationService.getCustomization('testItem');
|
||||
|
||||
const props = { testAttribute: 'testAttrValue' };
|
||||
const result = item.content(props);
|
||||
expect(result.label).toBe('other');
|
||||
expect(result.value).toBe(props.testAttribute);
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge', () => {
|
||||
it('appends to global configuration', () => {
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
customizationService.setGlobalCustomization('appendSet', {
|
||||
values: [{ id: 'one' }, { id: 'two' }],
|
||||
});
|
||||
const appendSet = customizationService.getCustomization('appendSet');
|
||||
expect(appendSet.values.length).toBe(2);
|
||||
|
||||
customizationService.setGlobalCustomization(
|
||||
'appendSet',
|
||||
{
|
||||
values: [{ id: 'three' }],
|
||||
},
|
||||
MergeEnum.Append
|
||||
);
|
||||
const appendSet2 = customizationService.getCustomization('appendSet');
|
||||
expect(appendSet2.values.length).toBe(3);
|
||||
});
|
||||
|
||||
it('appends mode to default without touching default', () => {
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
customizationService.setDefaultCustomization('appendSet', {
|
||||
values: [{ id: 'one' }, { id: 'two' }],
|
||||
});
|
||||
const appendSet = customizationService.get('appendSet');
|
||||
expect(appendSet.values.length).toBe(2);
|
||||
|
||||
customizationService.setModeCustomization(
|
||||
'appendSet',
|
||||
{
|
||||
values: [{ id: 'three' }],
|
||||
},
|
||||
MergeEnum.Append
|
||||
);
|
||||
|
||||
expect(appendSet.values.length).toBe(2);
|
||||
const appendSet2 = customizationService.getModeCustomization('appendSet');
|
||||
expect(appendSet2.values.length).toBe(3);
|
||||
});
|
||||
|
||||
it('merges values by name/position', () => {
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
customizationService.setDefaultCustomization('appendSet', {
|
||||
values: [{ id: 'one', obj: { v: '5' }, list: [1, 2, 3] }, { id: 'two' }],
|
||||
});
|
||||
const appendSet = customizationService.get('appendSet');
|
||||
expect(appendSet.values.length).toBe(2);
|
||||
|
||||
customizationService.setModeCustomization(
|
||||
'appendSet',
|
||||
{
|
||||
values: [{ id: 'three', obj: { v: 2 }, list: [3, 2, 1, 4] }],
|
||||
},
|
||||
MergeEnum.Merge,
|
||||
);
|
||||
|
||||
const appendSet2 = customizationService.get('appendSet');
|
||||
const [value0] = appendSet2.values;
|
||||
expect(value0.id).toBe('three');
|
||||
expect(value0.list).toEqual([3, 2, 1, 4]);
|
||||
});
|
||||
|
||||
it('merges functions', () => {
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
customizationService.setDefaultCustomization('appendSet', {
|
||||
values: [{ f: () => 0, id: '0' }, { f: () => 5, id: '5' }],
|
||||
});
|
||||
const appendSet = customizationService.get('appendSet');
|
||||
expect(appendSet.values.length).toBe(2);
|
||||
|
||||
customizationService.setModeCustomization(
|
||||
'appendSet',
|
||||
{
|
||||
values: [{ f: () => 2, id: '2' }]
|
||||
},
|
||||
MergeEnum.Merge,
|
||||
);
|
||||
|
||||
const appendSet2 = customizationService.get('appendSet');
|
||||
const [value0, value1] = appendSet2.values;
|
||||
expect(value0.f()).toBe(2);
|
||||
expect(value1.f()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges list with object', () => {
|
||||
customizationService.init(extensionManager);
|
||||
|
||||
const destination = [
|
||||
1,
|
||||
{ id: 'two', value: 2, list: [5, 6], },
|
||||
{ id: 'three', value: 3 }
|
||||
];
|
||||
|
||||
const source = {
|
||||
two: { value: 'updated2', list: { 0: 8 } },
|
||||
1: { extraValue: 2, list: [7], },
|
||||
1.0001: { id: 'inserted', value: 1.0001 },
|
||||
'-1': {
|
||||
value: -3
|
||||
},
|
||||
};
|
||||
|
||||
customizationService.setDefaultCustomization('appendSet', {
|
||||
values: destination,
|
||||
});
|
||||
customizationService.setModeCustomization('appendSet', {
|
||||
values: source,
|
||||
}, MergeEnum.Append);
|
||||
|
||||
const { values } = customizationService.getCustomization('appendSet');
|
||||
const [zero, one, two, three] = values;
|
||||
expect(zero).toBe(1);
|
||||
expect(one.value).toBe('updated2');
|
||||
expect(one.extraValue).toBe(2);
|
||||
expect(one.list).toEqual([8, 6, 7]);
|
||||
expect(two.id).toBe('inserted');
|
||||
expect(three.value).toBe(-3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { mergeWith, cloneDeepWith } from 'lodash';
|
||||
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import type { Customization, NestedStrings } from './types';
|
||||
import type { CommandsManager } from '../../classes';
|
||||
import type { ExtensionManager } from '../../extensions';
|
||||
|
||||
const EVENTS = {
|
||||
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
|
||||
GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
|
||||
};
|
||||
|
||||
const flattenNestedStrings = (
|
||||
strs: NestedStrings | string,
|
||||
ret?: Record<string, string>
|
||||
): Record<string, string> => {
|
||||
if (!ret) {
|
||||
ret = {};
|
||||
}
|
||||
if (!strs) {
|
||||
return ret;
|
||||
}
|
||||
if (Array.isArray(strs)) {
|
||||
for (const val of strs) {
|
||||
flattenNestedStrings(val, ret);
|
||||
}
|
||||
} else {
|
||||
ret[strs] = strs;
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
export enum MergeEnum {
|
||||
/**
|
||||
* Append values in the nested arrays
|
||||
*/
|
||||
Append = 'Append',
|
||||
/**
|
||||
* Merge values, replacing arrays
|
||||
*/
|
||||
Merge = 'Merge',
|
||||
/**
|
||||
* Replace the given value - this is the default
|
||||
*/
|
||||
Replace = 'Replace',
|
||||
}
|
||||
|
||||
export enum CustomizationType {
|
||||
Global = 'Global',
|
||||
Mode = 'Mode',
|
||||
Default = 'Default',
|
||||
}
|
||||
|
||||
/**
|
||||
* The CustomizationService allows for retrieving of custom components
|
||||
* and configuration for mode and global values.
|
||||
* The intent of the items is to provide a react component. This can be
|
||||
* done by straight out providing an entire react component or else can be
|
||||
* done by configuring a react component, or configuring a part of a react
|
||||
* component. These are intended to be fairly indistinguishable in use of
|
||||
* it, although the internals of how that is implemented may need to know
|
||||
* about the customization service.
|
||||
*
|
||||
* A customization value can be:
|
||||
* 1. React function, taking (React, props) and returning a rendered component
|
||||
* For example, createLogoComponentFn renders a component logo for display
|
||||
* 2. Custom UI component configuration, as defined by the component which uses it.
|
||||
* For example, context menus define a complex structure allowing site-determined
|
||||
* context menus to be set.
|
||||
* 3. A string name, being the extension id for retrieving one of the above.
|
||||
*
|
||||
* The default values for the extension come from the app_config value 'whiteLabeling',
|
||||
* The whiteLabelling can have lists of extensions to load for the default global and
|
||||
* mode extensions. These are:
|
||||
* 'globalExtensions' which is a list of extension id's to load for global values
|
||||
* 'modeExtensions' which is a list of extension id's to load for mode values
|
||||
* They default to the list ['*'] if not otherwise provided, which means to check
|
||||
* every module for the given id and to load it/add it to the extensions.
|
||||
*/
|
||||
export default class CustomizationService extends PubSubService {
|
||||
public static REGISTRATION = {
|
||||
name: 'customizationService',
|
||||
create: ({ configuration = {}, commandsManager }) => {
|
||||
return new CustomizationService({ configuration, commandsManager });
|
||||
},
|
||||
};
|
||||
|
||||
commandsManager: CommandsManager;
|
||||
extensionManager: ExtensionManager;
|
||||
|
||||
/**
|
||||
* mode customizations are changes to the default behaviour which are reset
|
||||
* every time a new mode is entered. This allows the mode to define custom
|
||||
* behaviour, and not interfere with other modes.
|
||||
*/
|
||||
private modeCustomizations = new Map<string, Customization>();
|
||||
/**
|
||||
* global customizations, are customizations which are set as a global default
|
||||
* This allows changes across the board to be applied, essentially as a priority
|
||||
* setting.
|
||||
*/
|
||||
private globalCustomizations = new Map<string, Customization>();
|
||||
|
||||
/**
|
||||
* Default customizations allow applying default values. The intent is that
|
||||
* there is only one customization of that type, and it is registered at setup
|
||||
* time.
|
||||
*/
|
||||
private defaultCustomizations = new Map<string, Customization>();
|
||||
|
||||
/**
|
||||
* Has the transformed/final customization value. This avoids needing to
|
||||
* transform every time a customization is requested.
|
||||
*/
|
||||
private transformedCustomizations = new Map<string, Customization>();
|
||||
|
||||
configuration: any;
|
||||
|
||||
constructor({ configuration, commandsManager }) {
|
||||
super(EVENTS);
|
||||
this.commandsManager = commandsManager;
|
||||
this.configuration = configuration || {};
|
||||
}
|
||||
|
||||
public init(extensionManager: ExtensionManager): void {
|
||||
this.extensionManager = extensionManager;
|
||||
// Clear defaults as those are defined by the customization modules
|
||||
this.defaultCustomizations.clear();
|
||||
// Clear modes because those are defined in onModeEnter functions.
|
||||
this.modeCustomizations.clear();
|
||||
this.initDefaults();
|
||||
this.addReferences(this.configuration);
|
||||
}
|
||||
|
||||
initDefaults(): void {
|
||||
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
|
||||
const keyDefault = `${extensionId}.customizationModule.default`;
|
||||
const defaultCustomizations = this.findExtensionValue(keyDefault);
|
||||
if (defaultCustomizations) {
|
||||
const { value } = defaultCustomizations;
|
||||
this.addReference(value, CustomizationType.Default);
|
||||
}
|
||||
const keyGlobal = `${extensionId}.customizationModule.global`;
|
||||
const globalCustomizations = this.findExtensionValue(keyGlobal);
|
||||
if (globalCustomizations) {
|
||||
const { value } = globalCustomizations;
|
||||
this.addReference(value, CustomizationType.Global);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
findExtensionValue(value: string) {
|
||||
const entry = this.extensionManager.getModuleEntry(value);
|
||||
return entry as { value: Customization };
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
super.reset();
|
||||
|
||||
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
|
||||
for (const key of modeCustomizationKeys) {
|
||||
this.transformedCustomizations.delete(key);
|
||||
}
|
||||
|
||||
this.modeCustomizations.clear();
|
||||
}
|
||||
|
||||
public onModeExit(): void {
|
||||
this.onModeEnter();
|
||||
}
|
||||
|
||||
public getModeCustomizations(): Map<string, Customization> {
|
||||
return this.modeCustomizations;
|
||||
}
|
||||
|
||||
public setModeCustomization(
|
||||
customizationId: string,
|
||||
customization: Customization,
|
||||
merge = MergeEnum.Merge
|
||||
): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(customizationId);
|
||||
const modeCustomization = this.modeCustomizations.get(customizationId);
|
||||
const globCustomization = this.globalCustomizations.get(customizationId);
|
||||
const sourceCustomization =
|
||||
modeCustomization ||
|
||||
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
|
||||
defaultCustomization ||
|
||||
{};
|
||||
|
||||
// use the source merge type if not provided then fallback to merge
|
||||
this.modeCustomizations.set(
|
||||
customizationId,
|
||||
this.mergeValue(sourceCustomization, customization, sourceCustomization.merge ?? merge)
|
||||
);
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
|
||||
buttons: this.modeCustomizations,
|
||||
button: this.modeCustomizations.get(customizationId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the preferred getter for all customizations,
|
||||
* getting global (priority) customizations first,
|
||||
* then mode customizations, and finally the default customization.
|
||||
*
|
||||
* @param customizationId - the customization id to look for
|
||||
* @param defaultValue - is the default value to return.
|
||||
* This value will be assigned as the default customization if there isn't
|
||||
* currently a default customization, and thus, the first default provided
|
||||
* will be used as the default - you cannot update this after or have it depend
|
||||
* on changing values.
|
||||
* Also, the value returned by the get customization has merges/updates applied,
|
||||
* and is thus may be modified from the value provided, and may not be the original
|
||||
* default provided. This allows applying the defaults for things like inheritance.
|
||||
* @return A customization to use if one is found, or the default customization,
|
||||
* both enhanced with any customizationType inheritance (see transform)
|
||||
*/
|
||||
public getCustomization(customizationId: string, defaultValue?: Customization): Customization {
|
||||
const transformed = this.transformedCustomizations.get(customizationId);
|
||||
if (transformed) {
|
||||
return transformed;
|
||||
}
|
||||
if (defaultValue && !this.defaultCustomizations.has(customizationId)) {
|
||||
this.setDefaultCustomization(customizationId, defaultValue);
|
||||
}
|
||||
const customization =
|
||||
this.globalCustomizations.get(customizationId) ??
|
||||
this.modeCustomizations.get(customizationId) ??
|
||||
this.defaultCustomizations.get(customizationId);
|
||||
const newTransformed = this.transform(customization);
|
||||
if (newTransformed !== undefined) {
|
||||
this.transformedCustomizations.set(customizationId, newTransformed);
|
||||
}
|
||||
return newTransformed;
|
||||
}
|
||||
|
||||
/** Mode customizations are changes to the behavior of the extensions
|
||||
* when running in a given mode. Reset clears mode customizations.
|
||||
*
|
||||
* Note that global customizations over-ride mode customizations
|
||||
*
|
||||
* @param defaultValue to return if no customization specified.
|
||||
*/
|
||||
public getModeCustomization = this.getCustomization;
|
||||
|
||||
/**
|
||||
* Returns true if there is a mode customization. Doesn't include defaults, but
|
||||
* does return global overrides.
|
||||
*/
|
||||
public hasModeCustomization(customizationId: string) {
|
||||
return (
|
||||
this.globalCustomizations.has(customizationId) || this.modeCustomizations.has(customizationId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* get is an alias for getModeCustomization, as it is the generic getter
|
||||
* which will return both mode and global customizations, and should be
|
||||
* used generally.
|
||||
* Note that the second parameter, defaultValue, will be expanded to include
|
||||
* any customizationType values defined in it, so it is not the same as doing:
|
||||
* `customizationService.get('key') || defaultValue`
|
||||
* unless the defaultValue does not contain any customizationType definitions.
|
||||
*/
|
||||
public get = this.getModeCustomization;
|
||||
|
||||
/**
|
||||
* Applies any inheritance due to UI Type customization.
|
||||
* This will look for customizationType in the customization object
|
||||
* and if that is found, will assign all iterable values from that
|
||||
* type into the new type, allowing default behaviour to be configured.
|
||||
*/
|
||||
public transform(customization: Customization): Customization {
|
||||
if (!customization) {
|
||||
return customization;
|
||||
}
|
||||
const { customizationType } = customization;
|
||||
if (!customizationType) {
|
||||
return customization;
|
||||
}
|
||||
const parent = this.getCustomization(customizationType);
|
||||
const result = parent ? Object.assign(Object.create(parent), customization) : customization;
|
||||
// Execute an nested type information
|
||||
return result.transform?.(this) || result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to easily add and retrieve customizations
|
||||
* @param id The unique identifier for the customization
|
||||
* @param defaultComponent The default component to use if no customization is set
|
||||
* @param customComponent Optional custom component to set
|
||||
* @returns The custom component if set, otherwise the default component
|
||||
*/
|
||||
public getCustomComponent(
|
||||
id: string,
|
||||
defaultComponent: React.ComponentType<any>,
|
||||
customComponent?: React.ComponentType<any>
|
||||
) {
|
||||
const customization = this.getCustomization(id, {
|
||||
id: `default-${id}`,
|
||||
content: defaultComponent,
|
||||
});
|
||||
|
||||
if (customComponent) {
|
||||
this.setModeCustomization(id, { content: customComponent });
|
||||
}
|
||||
|
||||
return customization.content;
|
||||
}
|
||||
|
||||
public addModeCustomizations(modeCustomizations): void {
|
||||
if (!modeCustomizations) {
|
||||
return;
|
||||
}
|
||||
this.addReferences(modeCustomizations, CustomizationType.Mode);
|
||||
|
||||
this._broadcastModeCustomizationModified();
|
||||
}
|
||||
|
||||
_broadcastModeCustomizationModified(): void {
|
||||
this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
|
||||
modeCustomizations: this.modeCustomizations,
|
||||
globalCustomizations: this.globalCustomizations,
|
||||
});
|
||||
}
|
||||
|
||||
/** Global customizations are those that affect parts of the GUI other than
|
||||
* the modes. They include things like settings for the search screen.
|
||||
* Reset does NOT clear global customizations.
|
||||
*/
|
||||
getGlobalCustomization(id: string, defaultValue?: Customization): Customization | void {
|
||||
return this.transform(
|
||||
this.globalCustomizations.get(id) ?? this.defaultCustomizations.get(id) ?? defaultValue
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a merge, creating a new instance value - that is, not referencing
|
||||
* the old one. This only works if you run once for the merge, so in general,
|
||||
* the source value should be global, while the appends should be mode based.
|
||||
* However, you can append to a global value too, as long as you ensure it
|
||||
* only gets merged once.
|
||||
*/
|
||||
private mergeValue(oldValue, newValue, mergeType = MergeEnum.Replace) {
|
||||
if (mergeType === MergeEnum.Replace) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
const returnValue = mergeWith(
|
||||
{},
|
||||
oldValue,
|
||||
newValue,
|
||||
mergeType === MergeEnum.Append ? appendCustomizer : mergeCustomizer
|
||||
);
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
public setGlobalCustomization(id: string, value: Customization, merge = MergeEnum.Replace): void {
|
||||
const defaultCustomization = this.defaultCustomizations.get(id);
|
||||
const globCustomization = this.globalCustomizations.get(id);
|
||||
const sourceCustomization =
|
||||
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
|
||||
defaultCustomization ||
|
||||
{};
|
||||
this.globalCustomizations.set(
|
||||
id,
|
||||
this.mergeValue(sourceCustomization, value, value.merge ?? merge)
|
||||
);
|
||||
this.transformedCustomizations.clear();
|
||||
this._broadcastGlobalCustomizationModified();
|
||||
}
|
||||
|
||||
public setDefaultCustomization(
|
||||
id: string,
|
||||
value: Customization,
|
||||
merge = MergeEnum.Replace
|
||||
): void {
|
||||
if (this.defaultCustomizations.has(id)) {
|
||||
throw new Error(`Trying to update existing default for customization ${id}`);
|
||||
}
|
||||
this.transformedCustomizations.clear();
|
||||
this.defaultCustomizations.set(
|
||||
id,
|
||||
this.mergeValue(this.defaultCustomizations.get(id), value, merge)
|
||||
);
|
||||
}
|
||||
|
||||
protected setConfigGlobalCustomization(configuration: AppConfigCustomization): void {
|
||||
this.globalCustomizations.clear();
|
||||
const keys = flattenNestedStrings(configuration.globalCustomizations);
|
||||
this.readCustomizationTypes(v => keys[v.name] && v.customization, this.globalCustomizations);
|
||||
|
||||
// TODO - iterate over customizations, loading them from the extension
|
||||
// manager.
|
||||
this._broadcastGlobalCustomizationModified();
|
||||
}
|
||||
|
||||
_broadcastGlobalCustomizationModified(): void {
|
||||
this._broadcastEvent(EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
|
||||
modeCustomizations: this.modeCustomizations,
|
||||
globalCustomizations: this.globalCustomizations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A single reference is either an string to be loaded from a module,
|
||||
* or a customization itself.
|
||||
*/
|
||||
addReference(value?, type = CustomizationType.Global, id?: string, merge?: MergeEnum): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const extensionValue = this.findExtensionValue(value);
|
||||
// The child of a reference is only a set of references when an array,
|
||||
// so call the addReference direct. It could be a secondary reference perhaps
|
||||
this.addReference(extensionValue.value, type, extensionValue.name, extensionValue.merge);
|
||||
} else if (Array.isArray(value)) {
|
||||
this.addReferences(value, type);
|
||||
} else {
|
||||
const useId = value.id || id;
|
||||
const setName =
|
||||
(type === CustomizationType.Global && 'setGlobalCustomization') ||
|
||||
(type === CustomizationType.Default && 'setDefaultCustomization') ||
|
||||
'setModeCustomization';
|
||||
this[setName](useId as string, value, merge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizations can be specified as an array of strings or customizations,
|
||||
* or as an object whose key is the reference id, and the value is the string
|
||||
* or customization.
|
||||
*/
|
||||
addReferences(references?, type = CustomizationType.Global): void {
|
||||
if (!references) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(references)) {
|
||||
references.forEach(item => {
|
||||
this.addReference(item, type);
|
||||
});
|
||||
} else {
|
||||
for (const key of Object.keys(references)) {
|
||||
const value = references[key];
|
||||
this.addReference(value, type, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom merging function, to handle merging arrays and copying functions
|
||||
*/
|
||||
function appendCustomizer(obj, src) {
|
||||
if (Array.isArray(obj)) {
|
||||
const srcArray = Array.isArray(src);
|
||||
if (srcArray) {
|
||||
return obj.concat(...src);
|
||||
}
|
||||
if (typeof src === 'object') {
|
||||
const newList = obj.map(value => cloneDeepWith(value, cloneCustomizer));
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
const { position, isMerge } = findPosition(key, value, newList);
|
||||
if (isMerge) {
|
||||
if (typeof obj[position] === 'object') {
|
||||
newList[position] = mergeWith(
|
||||
Array.isArray(newList[position]) ? [] : {},
|
||||
newList[position],
|
||||
value,
|
||||
appendCustomizer
|
||||
);
|
||||
} else {
|
||||
newList[position] = value;
|
||||
}
|
||||
} else {
|
||||
newList.splice(position, 0, value);
|
||||
}
|
||||
}
|
||||
return newList;
|
||||
}
|
||||
return obj.concat(src);
|
||||
}
|
||||
return cloneCustomizer(src);
|
||||
}
|
||||
|
||||
function mergeCustomizer(obj, src) {
|
||||
return cloneCustomizer(src);
|
||||
}
|
||||
|
||||
function findPosition(key, value, newList) {
|
||||
const numVal = Number(key);
|
||||
const isNumeric = !isNaN(numVal);
|
||||
const { length: len } = newList;
|
||||
|
||||
if (isNumeric) {
|
||||
if (newList[numVal < 0 ? numVal + len : numVal]) {
|
||||
return { isMerge: true, position: (numVal + len) % len };
|
||||
}
|
||||
const absPosition = Math.ceil(numVal < 0 ? len + numVal : numVal);
|
||||
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
|
||||
}
|
||||
const findIndex = newList.findIndex(it => it.id === key);
|
||||
if (findIndex !== -1) {
|
||||
return { isMerge: true, position: findIndex };
|
||||
}
|
||||
const { _priority: priority } = value;
|
||||
if (priority !== undefined) {
|
||||
if (newList[(priority + len) % len]) {
|
||||
return { isMerge: true, position: (priority + len) % len };
|
||||
}
|
||||
const absPosition = Math.ceil(priority < 0 ? len + priority : priority);
|
||||
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
|
||||
}
|
||||
return { isMerge: false, position: len };
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom cloning function to just copy function reference
|
||||
*/
|
||||
function cloneCustomizer(value) {
|
||||
if (typeof value === 'function') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/CustomizationService/index.ts
Normal file
3
platform/core/src/services/CustomizationService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
export default CustomizationService;
|
||||
45
platform/core/src/services/CustomizationService/types.ts
Normal file
45
platform/core/src/services/CustomizationService/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Command } from '../../types/Command';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
export type Obj = Record<string, unknown>;
|
||||
|
||||
export interface BaseCustomization extends Obj {
|
||||
id: string;
|
||||
customizationType?: string;
|
||||
description?: string;
|
||||
label?: string;
|
||||
commands?: Command[];
|
||||
content?: (...props: any) => React.JSX.Element;
|
||||
}
|
||||
|
||||
export interface LabelCustomization extends BaseCustomization {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CodeCustomization extends BaseCustomization {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface CommandCustomization extends BaseCustomization {
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
export interface ComponentCustomization extends BaseCustomization {
|
||||
content: (...props: any) => React.JSX.Element;
|
||||
}
|
||||
|
||||
export type Customization =
|
||||
| BaseCustomization
|
||||
| LabelCustomization
|
||||
| CommandCustomization
|
||||
| CodeCustomization
|
||||
| ComponentCustomization;
|
||||
|
||||
export default Customization;
|
||||
|
||||
export type ComponentReturn = {
|
||||
component: ComponentType;
|
||||
props?: Obj;
|
||||
};
|
||||
|
||||
export type NestedStrings = string[] | NestedStrings[];
|
||||
@@ -0,0 +1,270 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
import createStudyMetadata from './createStudyMetadata';
|
||||
|
||||
const EVENTS = {
|
||||
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
|
||||
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
|
||||
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
|
||||
SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated',
|
||||
};
|
||||
|
||||
/**
|
||||
* @example
|
||||
* studies: [
|
||||
* {
|
||||
* StudyInstanceUID: string,
|
||||
* isLoaded: boolean,
|
||||
* series: [
|
||||
* {
|
||||
* Modality: string,
|
||||
* SeriesInstanceUID: string,
|
||||
* SeriesNumber: number,
|
||||
* SeriesDescription: string,
|
||||
* instances: [
|
||||
* {
|
||||
* // naturalized instance metadata
|
||||
* SOPInstanceUID: string,
|
||||
* SOPClassUID: string,
|
||||
* Rows: number,
|
||||
* Columns: number,
|
||||
* PatientSex: string,
|
||||
* Modality: string,
|
||||
* InstanceNumber: string,
|
||||
* },
|
||||
* {
|
||||
* // instance 2
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* {
|
||||
* // series 2
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* ],
|
||||
*/
|
||||
const _model = {
|
||||
studies: [],
|
||||
};
|
||||
|
||||
function _getStudyInstanceUIDs() {
|
||||
return _model.studies.map(aStudy => aStudy.StudyInstanceUID);
|
||||
}
|
||||
|
||||
function _getStudy(StudyInstanceUID) {
|
||||
return _model.studies.find(aStudy => aStudy.StudyInstanceUID === StudyInstanceUID);
|
||||
}
|
||||
|
||||
function _getSeries(StudyInstanceUID, SeriesInstanceUID) {
|
||||
const study = _getStudy(StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
return;
|
||||
}
|
||||
|
||||
return study.series.find(aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID);
|
||||
}
|
||||
|
||||
function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
|
||||
const series = _getSeries(StudyInstanceUID, SeriesInstanceUID);
|
||||
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
return series.getInstance(SOPInstanceUID);
|
||||
}
|
||||
|
||||
function _getInstanceByImageId(imageId) {
|
||||
for (const study of _model.studies) {
|
||||
for (const series of study.series) {
|
||||
for (const instance of series.instances) {
|
||||
if (instance.imageId === imageId) {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the metadata of a specific series
|
||||
* @param {*} StudyInstanceUID
|
||||
* @param {*} SeriesInstanceUID
|
||||
* @param {*} metadata metadata inform of key value pairs
|
||||
* @returns
|
||||
*/
|
||||
function _updateMetadataForSeries(StudyInstanceUID, SeriesInstanceUID, metadata) {
|
||||
const study = _getStudy(StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = study.series.find(aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID);
|
||||
|
||||
const { instances } = series;
|
||||
// update all instances metadata for this series with the new metadata
|
||||
instances.forEach(instance => {
|
||||
Object.keys(metadata).forEach(key => {
|
||||
// if metadata[key] is an object, we need to merge it with the existing
|
||||
// metadata of the instance
|
||||
if (typeof metadata[key] === 'object') {
|
||||
instance[key] = { ...instance[key], ...metadata[key] };
|
||||
}
|
||||
// otherwise, we just replace the existing metadata with the new one
|
||||
else {
|
||||
instance[key] = metadata[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// broadcast the series updated event
|
||||
this._broadcastEvent(EVENTS.SERIES_UPDATED, {
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
madeInClient: true,
|
||||
});
|
||||
}
|
||||
|
||||
const BaseImplementation = {
|
||||
EVENTS,
|
||||
listeners: {},
|
||||
addInstance(dicomJSONDatasetOrP10ArrayBuffer) {
|
||||
let dicomJSONDataset;
|
||||
|
||||
// If Arraybuffer, parse to DICOMJSON before naturalizing.
|
||||
if (dicomJSONDatasetOrP10ArrayBuffer instanceof ArrayBuffer) {
|
||||
const dicomData = dcmjs.data.DicomMessage.readFile(dicomJSONDatasetOrP10ArrayBuffer);
|
||||
|
||||
dicomJSONDataset = dicomData.dict;
|
||||
} else {
|
||||
dicomJSONDataset = dicomJSONDatasetOrP10ArrayBuffer;
|
||||
}
|
||||
|
||||
let naturalizedDataset;
|
||||
|
||||
if (dicomJSONDataset['SeriesInstanceUID'] === undefined) {
|
||||
naturalizedDataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomJSONDataset);
|
||||
} else {
|
||||
naturalizedDataset = dicomJSONDataset;
|
||||
}
|
||||
|
||||
const { StudyInstanceUID } = naturalizedDataset;
|
||||
|
||||
let study = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
_model.studies.push(createStudyMetadata(StudyInstanceUID));
|
||||
study = _model.studies[_model.studies.length - 1];
|
||||
}
|
||||
|
||||
study.addInstanceToSeries(naturalizedDataset);
|
||||
},
|
||||
addInstances(instances, madeInClient = false) {
|
||||
const { StudyInstanceUID, SeriesInstanceUID } = instances[0];
|
||||
|
||||
let study = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
_model.studies.push(createStudyMetadata(StudyInstanceUID));
|
||||
|
||||
study = _model.studies[_model.studies.length - 1];
|
||||
}
|
||||
|
||||
study.addInstancesToSeries(instances);
|
||||
|
||||
// Broadcast an event even if we used cached data.
|
||||
// This is because the mode needs to listen to instances that are added to build up its active displaySets.
|
||||
// It will see there are cached displaySets and end early if this Series has already been fired in this
|
||||
// Mode session for some reason.
|
||||
this._broadcastEvent(EVENTS.INSTANCES_ADDED, {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
madeInClient,
|
||||
});
|
||||
},
|
||||
updateSeriesMetadata(seriesMetadata) {
|
||||
const { StudyInstanceUID, SeriesInstanceUID } = seriesMetadata;
|
||||
const series = _getSeries(StudyInstanceUID, SeriesInstanceUID);
|
||||
if (!series) {
|
||||
return;
|
||||
}
|
||||
|
||||
const study = _getStudy(StudyInstanceUID);
|
||||
if (study) {
|
||||
study.setSeriesMetadata(SeriesInstanceUID, seriesMetadata);
|
||||
}
|
||||
},
|
||||
addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) {
|
||||
if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { StudyInstanceUID } = seriesSummaryMetadata[0];
|
||||
let study = _getStudy(StudyInstanceUID);
|
||||
if (!study) {
|
||||
study = createStudyMetadata(StudyInstanceUID);
|
||||
// Will typically be undefined with a compliant DICOMweb server, reset later
|
||||
study.StudyDescription = seriesSummaryMetadata[0].StudyDescription;
|
||||
seriesSummaryMetadata.forEach(item => {
|
||||
if (study.ModalitiesInStudy.indexOf(item.Modality) === -1) {
|
||||
study.ModalitiesInStudy.push(item.Modality);
|
||||
}
|
||||
});
|
||||
study.NumberOfStudyRelatedSeries = seriesSummaryMetadata.length;
|
||||
_model.studies.push(study);
|
||||
}
|
||||
|
||||
seriesSummaryMetadata.forEach(series => {
|
||||
const { SeriesInstanceUID } = series;
|
||||
|
||||
study.setSeriesMetadata(SeriesInstanceUID, series);
|
||||
});
|
||||
|
||||
this._broadcastEvent(EVENTS.SERIES_ADDED, {
|
||||
StudyInstanceUID,
|
||||
seriesSummaryMetadata,
|
||||
madeInClient,
|
||||
});
|
||||
},
|
||||
addStudy(study) {
|
||||
const { StudyInstanceUID } = study;
|
||||
|
||||
const existingStudy = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
|
||||
|
||||
if (!existingStudy) {
|
||||
const newStudy = createStudyMetadata(StudyInstanceUID);
|
||||
|
||||
newStudy.PatientID = study.PatientID;
|
||||
newStudy.PatientName = study.PatientName;
|
||||
newStudy.StudyDate = study.StudyDate;
|
||||
newStudy.ModalitiesInStudy = study.ModalitiesInStudy;
|
||||
newStudy.StudyDescription = study.StudyDescription;
|
||||
newStudy.AccessionNumber = study.AccessionNumber;
|
||||
newStudy.NumInstances = study.NumInstances; // todo: Correct naming?
|
||||
|
||||
_model.studies.push(newStudy);
|
||||
}
|
||||
},
|
||||
getStudyInstanceUIDs: _getStudyInstanceUIDs,
|
||||
getStudy: _getStudy,
|
||||
getSeries: _getSeries,
|
||||
getInstance: _getInstance,
|
||||
getInstanceByImageId: _getInstanceByImageId,
|
||||
updateMetadataForSeries: _updateMetadataForSeries,
|
||||
};
|
||||
const DicomMetadataStore = Object.assign(
|
||||
// get study
|
||||
|
||||
// iterate over all series
|
||||
|
||||
{},
|
||||
BaseImplementation,
|
||||
pubSubServiceInterface
|
||||
);
|
||||
|
||||
export { DicomMetadataStore };
|
||||
export default DicomMetadataStore;
|
||||
@@ -0,0 +1,27 @@
|
||||
function createSeriesMetadata(SeriesInstanceUID) {
|
||||
const instances = [];
|
||||
const instancesMap = new Map();
|
||||
|
||||
return {
|
||||
SeriesInstanceUID,
|
||||
instances,
|
||||
addInstance: function (newInstance) {
|
||||
this.addInstances([newInstance]);
|
||||
},
|
||||
addInstances: function (newInstances) {
|
||||
for (let i = 0, len = newInstances.length; i < len; i++) {
|
||||
const instance = newInstances[i];
|
||||
|
||||
if (!instancesMap.has(instance.SOPInstanceUID)) {
|
||||
instancesMap.set(instance.SOPInstanceUID, instance);
|
||||
instances.push(instance);
|
||||
}
|
||||
}
|
||||
},
|
||||
getInstance: function (SOPInstanceUID) {
|
||||
return instancesMap.get(SOPInstanceUID);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createSeriesMetadata;
|
||||
@@ -0,0 +1,49 @@
|
||||
import createSeriesMetadata from './createSeriesMetadata';
|
||||
|
||||
function createStudyMetadata(StudyInstanceUID) {
|
||||
return {
|
||||
StudyInstanceUID,
|
||||
StudyDescription: '',
|
||||
ModalitiesInStudy: [],
|
||||
isLoaded: false,
|
||||
series: [],
|
||||
/**
|
||||
* @param {object} instance
|
||||
*/
|
||||
addInstanceToSeries: function (instance) {
|
||||
this.addInstancesToSeries([instance]);
|
||||
},
|
||||
/**
|
||||
* @param {object[]} instances
|
||||
* @param {string} instances[].SeriesInstanceUID
|
||||
* @param {string} instances[].StudyDescription
|
||||
*/
|
||||
addInstancesToSeries: function (instances) {
|
||||
const { SeriesInstanceUID } = instances[0];
|
||||
if (!this.StudyDescription) {
|
||||
this.StudyDescription = instances[0].StudyDescription;
|
||||
}
|
||||
let series = this.series.find(s => s.SeriesInstanceUID === SeriesInstanceUID);
|
||||
|
||||
if (!series) {
|
||||
series = createSeriesMetadata(SeriesInstanceUID);
|
||||
this.series.push(series);
|
||||
}
|
||||
|
||||
series.addInstances(instances);
|
||||
},
|
||||
|
||||
setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) {
|
||||
let existingSeries = this.series.find(s => s.SeriesInstanceUID === SeriesInstanceUID);
|
||||
|
||||
if (existingSeries) {
|
||||
existingSeries = Object.assign(existingSeries, seriesMetadata);
|
||||
} else {
|
||||
const series = createSeriesMetadata(SeriesInstanceUID);
|
||||
this.series.push(Object.assign(series, seriesMetadata));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createStudyMetadata;
|
||||
4
platform/core/src/services/DicomMetadataStore/index.ts
Normal file
4
platform/core/src/services/DicomMetadataStore/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import DicomMetadataStore from './DicomMetadataStore';
|
||||
|
||||
export { DicomMetadataStore };
|
||||
export default DicomMetadataStore;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Defines a displaySet message, that could be any pf the potential problems of a displaySet
|
||||
*/
|
||||
class DisplaySetMessage {
|
||||
id: number;
|
||||
static CODES = {
|
||||
NO_VALID_INSTANCES: 1,
|
||||
NO_POSITION_INFORMATION: 2,
|
||||
NOT_RECONSTRUCTABLE: 3,
|
||||
MULTIFRAME_NO_PIXEL_MEASUREMENTS: 4,
|
||||
MULTIFRAME_NO_ORIENTATION: 5,
|
||||
MULTIFRAME_NO_POSITION_INFORMATION: 6,
|
||||
MISSING_FRAMES: 7,
|
||||
IRREGULAR_SPACING: 8,
|
||||
INCONSISTENT_DIMENSIONS: 9,
|
||||
INCONSISTENT_COMPONENTS: 10,
|
||||
INCONSISTENT_ORIENTATIONS: 11,
|
||||
INCONSISTENT_POSITION_INFORMATION: 12,
|
||||
UNSUPPORTED_DISPLAYSET: 13,
|
||||
};
|
||||
|
||||
constructor(id: number) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Defines a list of displaySet messages
|
||||
*/
|
||||
class DisplaySetMessageList {
|
||||
messages = [];
|
||||
|
||||
public addMessage(messageId: number): void {
|
||||
const message = new DisplaySetMessage(messageId);
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.messages.length;
|
||||
}
|
||||
|
||||
public includesMessage(messageId: number): boolean {
|
||||
return this.messages.some(message => message.id === messageId);
|
||||
}
|
||||
|
||||
public includesAllMessages(messageIdList: number[]): boolean {
|
||||
return messageIdList.every(messageId => this.include(messageId));
|
||||
}
|
||||
}
|
||||
|
||||
export { DisplaySetMessage, DisplaySetMessageList };
|
||||
@@ -0,0 +1,447 @@
|
||||
import { ExtensionManager } from '../../extensions';
|
||||
import { DisplaySet, InstanceMetadata } from '../../types';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import EVENTS from './EVENTS';
|
||||
|
||||
const displaySetCache = new Map<string, DisplaySet>();
|
||||
|
||||
/**
|
||||
* Filters the instances set by instances not in
|
||||
* display sets. Done in O(n) time.
|
||||
*/
|
||||
const filterInstances = (
|
||||
instances: InstanceMetadata[],
|
||||
displaySets: DisplaySet[]
|
||||
): InstanceMetadata[] => {
|
||||
const dsInstancesSOP = new Set();
|
||||
displaySets.forEach(ds => {
|
||||
const dsInstances = ds.instances;
|
||||
if (!dsInstances) {
|
||||
console.warn('No instances in', ds);
|
||||
} else {
|
||||
dsInstances.forEach(instance => dsInstancesSOP.add(instance.SOPInstanceUID));
|
||||
}
|
||||
});
|
||||
|
||||
return instances.filter(instance => !dsInstancesSOP.has(instance.SOPInstanceUID));
|
||||
};
|
||||
|
||||
export default class DisplaySetService extends PubSubService {
|
||||
public static REGISTRATION = {
|
||||
altName: 'DisplaySetService',
|
||||
name: 'displaySetService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new DisplaySetService();
|
||||
},
|
||||
};
|
||||
|
||||
public activeDisplaySets = [];
|
||||
public unsupportedSOPClassHandler;
|
||||
extensionManager: ExtensionManager;
|
||||
|
||||
protected activeDisplaySetsMap = new Map<string, DisplaySet>();
|
||||
|
||||
// Record if the active display sets changed - used to group change events so
|
||||
// that fewer events need to be fired when creating multiple display sets
|
||||
protected activeDisplaySetsChanged = false;
|
||||
|
||||
constructor() {
|
||||
super(EVENTS);
|
||||
this.unsupportedSOPClassHandler =
|
||||
'@ohif/extension-default.sopClassHandlerModule.not-supported-display-sets-handler';
|
||||
}
|
||||
|
||||
public init(extensionManager, SOPClassHandlerIds): void {
|
||||
this.extensionManager = extensionManager;
|
||||
this.SOPClassHandlerIds = SOPClassHandlerIds;
|
||||
this.activeDisplaySets = [];
|
||||
this.activeDisplaySetsMap.clear();
|
||||
}
|
||||
|
||||
_addDisplaySetsToCache(displaySets: DisplaySet[]) {
|
||||
displaySets.forEach(displaySet => {
|
||||
displaySetCache.set(displaySet.displaySetInstanceUID, displaySet);
|
||||
});
|
||||
}
|
||||
|
||||
_addActiveDisplaySets(displaySets: DisplaySet[]) {
|
||||
const { activeDisplaySets, activeDisplaySetsMap } = this;
|
||||
|
||||
displaySets.forEach(displaySet => {
|
||||
if (!activeDisplaySetsMap.has(displaySet.displaySetInstanceUID)) {
|
||||
this.activeDisplaySetsChanged = true;
|
||||
activeDisplaySets.push(displaySet);
|
||||
activeDisplaySetsMap.set(displaySet.displaySetInstanceUID, displaySet);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the handler for unsupported sop classes
|
||||
* @param sopClassHandlerUID
|
||||
*/
|
||||
public setUnsuportedSOPClassHandler(sopClassHandler) {
|
||||
this.unsupportedSOPClassHandler = sopClassHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new display sets directly, as specified.
|
||||
* Use this function when the display sets are created externally directly
|
||||
* rather than using the default sop class handlers to create display sets.
|
||||
*/
|
||||
public addDisplaySets(...displaySets: DisplaySet[]): string[] {
|
||||
this._addDisplaySetsToCache(displaySets);
|
||||
this._addActiveDisplaySets(displaySets);
|
||||
|
||||
// The activeDisplaySetsChanged flag is only seen if we add display sets
|
||||
// so, don't broadcast the change if all the display sets were pre-existing.
|
||||
this.activeDisplaySetsChanged = false;
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
|
||||
displaySetsAdded: displaySets,
|
||||
options: { madeInClient: displaySets[0].madeInClient },
|
||||
});
|
||||
return displaySets;
|
||||
}
|
||||
|
||||
public getDisplaySetCache(): Map<string, DisplaySet> {
|
||||
return displaySetCache;
|
||||
}
|
||||
|
||||
public getMostRecentDisplaySet(): DisplaySet {
|
||||
return this.activeDisplaySets[this.activeDisplaySets.length - 1];
|
||||
}
|
||||
|
||||
public getActiveDisplaySets(): DisplaySet[] {
|
||||
return this.activeDisplaySets;
|
||||
}
|
||||
|
||||
public getDisplaySetsForSeries = (seriesInstanceUID: string): DisplaySet[] => {
|
||||
return [...displaySetCache.values()].filter(
|
||||
displaySet => displaySet.SeriesInstanceUID === seriesInstanceUID
|
||||
);
|
||||
};
|
||||
|
||||
public getDisplaySetForSOPInstanceUID(
|
||||
sopInstanceUID: string,
|
||||
seriesInstanceUID: string,
|
||||
frameNumber?: number
|
||||
): DisplaySet {
|
||||
const displaySets = seriesInstanceUID
|
||||
? this.getDisplaySetsForSeries(seriesInstanceUID)
|
||||
: [...this.getDisplaySetCache().values()];
|
||||
|
||||
const displaySet = displaySets.find(ds => {
|
||||
return ds.instances?.some(i => i.SOPInstanceUID === sopInstanceUID);
|
||||
});
|
||||
|
||||
return displaySet;
|
||||
}
|
||||
|
||||
public setDisplaySetMetadataInvalidated(
|
||||
displaySetInstanceUID: string,
|
||||
invalidateData = true
|
||||
): void {
|
||||
const displaySet = this.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
if (!displaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
// broadcast event to update listeners with the new displaySets
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED, {
|
||||
displaySetInstanceUID,
|
||||
invalidateData,
|
||||
});
|
||||
}
|
||||
|
||||
public deleteDisplaySet(displaySetInstanceUID) {
|
||||
if (!displaySetInstanceUID) {
|
||||
return;
|
||||
}
|
||||
const { activeDisplaySets, activeDisplaySetsMap } = this;
|
||||
|
||||
const activeDisplaySetsIndex = activeDisplaySets.findIndex(
|
||||
ds => ds.displaySetInstanceUID === displaySetInstanceUID
|
||||
);
|
||||
|
||||
displaySetCache.delete(displaySetInstanceUID);
|
||||
activeDisplaySets.splice(activeDisplaySetsIndex, 1);
|
||||
activeDisplaySetsMap.delete(displaySetInstanceUID);
|
||||
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_REMOVED, {
|
||||
displaySetInstanceUIDs: [displaySetInstanceUID],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} displaySetInstanceUID
|
||||
* @returns {object} displaySet
|
||||
*/
|
||||
public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => {
|
||||
if (typeof displaySetInstanceUid !== 'string') {
|
||||
throw new Error(
|
||||
`getDisplaySetByUID: displaySetInstanceUid must be a string, you passed ${displaySetInstanceUid}`
|
||||
);
|
||||
}
|
||||
|
||||
return displaySetCache.get(displaySetInstanceUid);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} input
|
||||
* @param {*} param1: settings: initialViewportSettings by HP or callbacks after rendering
|
||||
* @returns {string[]} - added displaySetInstanceUIDs
|
||||
*/
|
||||
makeDisplaySets = (input, { batch = false, madeInClient = false, settings = {} } = {}) => {
|
||||
if (!input || !input.length) {
|
||||
throw new Error('No instances were provided.');
|
||||
}
|
||||
|
||||
if (batch && !input[0].length) {
|
||||
throw new Error('Batch displaySet creation does not contain array of array of instances.');
|
||||
}
|
||||
|
||||
// If array of instances => One instance.
|
||||
const displaySetsAdded = new Array<DisplaySet>();
|
||||
|
||||
if (batch) {
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const instances = input[i];
|
||||
const displaySets = this.makeDisplaySetForInstances(instances, settings);
|
||||
|
||||
displaySetsAdded.push(...displaySets);
|
||||
}
|
||||
} else {
|
||||
const displaySets = this.makeDisplaySetForInstances(input, settings);
|
||||
|
||||
displaySetsAdded.push(...displaySets);
|
||||
}
|
||||
|
||||
const options = {};
|
||||
|
||||
if (madeInClient) {
|
||||
options.madeInClient = true;
|
||||
}
|
||||
|
||||
if (this.activeDisplaySetsChanged) {
|
||||
this.activeDisplaySetsChanged = false;
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
|
||||
}
|
||||
if (displaySetsAdded?.length) {
|
||||
// The response from displaySetsAdded will only contain newly added
|
||||
// display sets.
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
|
||||
displaySetsAdded,
|
||||
options,
|
||||
});
|
||||
|
||||
return displaySetsAdded;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The onModeExit returns the display set service to the initial state,
|
||||
* that is without any display sets. To avoid recreating display sets,
|
||||
* the mode specific onModeExit is called before this method and should
|
||||
* store the active display sets and the cached data.
|
||||
*/
|
||||
public onModeExit(): void {
|
||||
this.getDisplaySetCache().clear();
|
||||
this.activeDisplaySets.length = 0;
|
||||
this.activeDisplaySetsMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function hides the old makeDisplaySetForInstances function to first
|
||||
* separate the instances by sopClassUID so each call have only instances
|
||||
* with the same sopClassUID, to avoid a series composed by different
|
||||
* sopClassUIDs be filtered inside one of the SOPClassHandler functions and
|
||||
* didn't appear in the series list.
|
||||
* @param instancesSrc
|
||||
* @param settings
|
||||
* @returns
|
||||
*/
|
||||
public makeDisplaySetForInstances(instancesSrc: InstanceMetadata[], settings): DisplaySet[] {
|
||||
// creating a sopClassUID list and for each sopClass associate its respective
|
||||
// instance list
|
||||
const instancesForSetSOPClasses = instancesSrc.reduce((sopClassList, instance) => {
|
||||
if (!(instance.SOPClassUID in sopClassList)) {
|
||||
sopClassList[instance.SOPClassUID] = [];
|
||||
}
|
||||
sopClassList[instance.SOPClassUID].push(instance);
|
||||
return sopClassList;
|
||||
}, {});
|
||||
// for each sopClassUID, call the old makeDisplaySetForInstances with a
|
||||
// instance list composed only by instances with the same sopClassUID and
|
||||
// accumulate the displaySets in the variable allDisplaySets
|
||||
const sopClasses = Object.keys(instancesForSetSOPClasses);
|
||||
let allDisplaySets = [];
|
||||
sopClasses.forEach(sopClass => {
|
||||
const displaySets = this._makeDisplaySetForInstances(
|
||||
instancesForSetSOPClasses[sopClass],
|
||||
settings
|
||||
);
|
||||
allDisplaySets = [...allDisplaySets, ...displaySets];
|
||||
});
|
||||
return allDisplaySets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new display sets for the instances contained in instancesSrc
|
||||
* according to the sop class handlers registered.
|
||||
* This is idempotent in that calling it a second time with the
|
||||
* same set of instances will not result in new display sets added.
|
||||
* However, the response for the subsequent call will be empty as the data
|
||||
* is already present.
|
||||
* Calling it with some new instances and some existing instances will
|
||||
* result in the new instances being added to existing display sets if
|
||||
* they support the addInstances call, OR to new instances otherwise.
|
||||
* Only the new instances are returned - the others are updated.
|
||||
*
|
||||
* @param instancesSrc are instances to add
|
||||
* @param settings are settings to add
|
||||
* @returns Array of the display sets added.
|
||||
*/
|
||||
private _makeDisplaySetForInstances(instancesSrc: InstanceMetadata[], settings): DisplaySet[] {
|
||||
// Some of the sop class handlers take a direct reference to instances
|
||||
// so make sure it gets copied here so that they have their own ref
|
||||
let instances = [...instancesSrc];
|
||||
const instance = instances[0];
|
||||
|
||||
const existingDisplaySets = this.getDisplaySetsForSeries(instance.SeriesInstanceUID) || [];
|
||||
|
||||
const SOPClassHandlerIds = this.SOPClassHandlerIds;
|
||||
const allDisplaySets = [];
|
||||
|
||||
// Iterate over the sop class handlers while there are still instances to add
|
||||
for (let i = 0; i < SOPClassHandlerIds.length && instances.length; i++) {
|
||||
const SOPClassHandlerId = SOPClassHandlerIds[i];
|
||||
const handler = this.extensionManager.getModuleEntry(SOPClassHandlerId);
|
||||
|
||||
if (handler.sopClassUids.includes(instance.SOPClassUID)) {
|
||||
// Check if displaySets are already created using this SeriesInstanceUID/SOPClassHandler pair.
|
||||
let displaySets = existingDisplaySets.filter(
|
||||
displaySet => displaySet.SOPClassHandlerId === SOPClassHandlerId
|
||||
);
|
||||
|
||||
if (displaySets.length) {
|
||||
// This case occurs when there are already display sets, so remove
|
||||
// any instances in existing display sets.
|
||||
instances = filterInstances(instances, displaySets);
|
||||
// See if an existing display set can add this instance to it,
|
||||
// for example, if it is a new image to be added to the existing set
|
||||
for (const ds of displaySets) {
|
||||
const addedDs = ds.addInstances?.(instances, this);
|
||||
if (addedDs) {
|
||||
this.activeDisplaySetsChanged = true;
|
||||
instances = filterInstances(instances, [addedDs]);
|
||||
this._addActiveDisplaySets([addedDs]);
|
||||
this.setDisplaySetMetadataInvalidated(addedDs.displaySetInstanceUID);
|
||||
}
|
||||
// This means that all instances already existed or got added to
|
||||
// existing display sets, and had an invalidated event fired
|
||||
if (!instances.length) {
|
||||
return allDisplaySets;
|
||||
}
|
||||
}
|
||||
|
||||
if (!instances.length) {
|
||||
// Everything is already added - this is just an update caused
|
||||
// by something else
|
||||
this._addActiveDisplaySets(displaySets);
|
||||
return allDisplaySets;
|
||||
}
|
||||
}
|
||||
|
||||
// The instances array still contains some instances, so try
|
||||
// creating additional display sets using the sop class handler
|
||||
displaySets = handler.getDisplaySetsFromSeries(instances);
|
||||
|
||||
if (!displaySets || !displaySets.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// applying hp-defined viewport settings to the displaysets
|
||||
displaySets.forEach(ds => {
|
||||
Object.keys(settings).forEach(key => {
|
||||
ds[key] = settings[key];
|
||||
});
|
||||
});
|
||||
|
||||
this._addDisplaySetsToCache(displaySets);
|
||||
this._addActiveDisplaySets(displaySets);
|
||||
|
||||
// It is possible that this SOP class handler handled some instances
|
||||
// but there may need to be other instances handled by other handlers,
|
||||
// so remove the handled instances
|
||||
instances = filterInstances(instances, displaySets);
|
||||
|
||||
allDisplaySets.push(...displaySets);
|
||||
}
|
||||
}
|
||||
// applying the default sopClassUID handler
|
||||
if (allDisplaySets.length === 0) {
|
||||
// applying hp-defined viewport settings to the displaysets
|
||||
const handler = this.extensionManager.getModuleEntry(this.unsupportedSOPClassHandler);
|
||||
const displaySets = handler.getDisplaySetsFromSeries(instances);
|
||||
if (displaySets?.length) {
|
||||
displaySets.forEach(ds => {
|
||||
Object.keys(settings).forEach(key => {
|
||||
ds[key] = settings[key];
|
||||
});
|
||||
});
|
||||
|
||||
this._addDisplaySetsToCache(displaySets);
|
||||
this._addActiveDisplaySets(displaySets);
|
||||
|
||||
allDisplaySets.push(...displaySets);
|
||||
}
|
||||
}
|
||||
return allDisplaySets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over displaysets and invokes comparator for each element.
|
||||
* It returns a list of items that has being succeed by comparator method.
|
||||
*
|
||||
* @param comparator - method to be used on the validation
|
||||
* @returns list of displaysets
|
||||
*/
|
||||
public getDisplaySetsBy(comparator: (DisplaySet) => boolean): DisplaySet[] {
|
||||
const result = [];
|
||||
|
||||
if (typeof comparator !== 'function') {
|
||||
throw new Error(`The comparator ${comparator} was not a function`);
|
||||
}
|
||||
|
||||
this.getActiveDisplaySets().forEach(displaySet => {
|
||||
if (comparator(displaySet)) {
|
||||
result.push(displaySet);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param sortFn function to sort the display sets
|
||||
* @param direction direction to sort the display sets
|
||||
* @returns void
|
||||
*/
|
||||
public sortDisplaySets(
|
||||
sortFn: (a: DisplaySet, b: DisplaySet) => number,
|
||||
direction: string,
|
||||
suppressEvent = false
|
||||
): void {
|
||||
this.activeDisplaySets.sort(sortFn);
|
||||
if (direction === 'descending') {
|
||||
this.activeDisplaySets.reverse();
|
||||
}
|
||||
if (!suppressEvent) {
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
platform/core/src/services/DisplaySetService/EVENTS.js
Normal file
9
platform/core/src/services/DisplaySetService/EVENTS.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const EVENTS = {
|
||||
DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded',
|
||||
DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged',
|
||||
DISPLAY_SETS_REMOVED: 'event::displaySetService:displaySetsRemoved',
|
||||
DISPLAY_SET_SERIES_METADATA_INVALIDATED:
|
||||
'event::displaySetService:displaySetSeriesMetadataInvalidated',
|
||||
};
|
||||
|
||||
export default EVENTS;
|
||||
5
platform/core/src/services/DisplaySetService/index.ts
Normal file
5
platform/core/src/services/DisplaySetService/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import DisplaySetService from './DisplaySetService';
|
||||
import { DisplaySetMessage, DisplaySetMessageList } from './DisplaySetMessage';
|
||||
|
||||
export default DisplaySetService;
|
||||
export { DisplaySetMessage, DisplaySetMessageList };
|
||||
173
platform/core/src/services/HangingProtocolService/HPMatcher.js
Normal file
173
platform/core/src/services/HangingProtocolService/HPMatcher.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import validate from './lib/validator';
|
||||
|
||||
/**
|
||||
* Match a Metadata instance against rules using Validate.js for validation.
|
||||
* @param {InstanceMetadata} metadataInstance Metadata instance object
|
||||
* @param {Array} rules Array of MatchingRules instances (StudyMatchingRule|SeriesMatchingRule|ImageMatchingRule) for the match
|
||||
* @param {object} options is an object containing additional information
|
||||
* @param {object[]} options.studies is a list of all the studies
|
||||
* @param {object[]} options.displaySets is a list of the display sets
|
||||
* @return {Object} Matching Object with score and details (which rule passed or failed)
|
||||
*/
|
||||
const match = (metadataInstance, rules = [], customAttributeRetrievalCallbacks, options) => {
|
||||
const validateOptions = {
|
||||
format: 'grouped',
|
||||
};
|
||||
|
||||
const details = {
|
||||
passed: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
const readValues = {};
|
||||
|
||||
let requiredFailed = false;
|
||||
let score = 0;
|
||||
|
||||
// Allow for matching against current or prior specifically
|
||||
const prior = options?.studies?.[1];
|
||||
const current = options?.studies?.[0];
|
||||
const instance = metadataInstance.instances?.[0];
|
||||
const fromSrc = {
|
||||
prior,
|
||||
current,
|
||||
instance,
|
||||
...options,
|
||||
options,
|
||||
metadataInstance,
|
||||
};
|
||||
|
||||
rules.forEach(rule => {
|
||||
const { attribute, from = 'metadataInstance' } = rule;
|
||||
// Do not use the custom attribute from the metadataInstance since it is subject to change
|
||||
if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
|
||||
readValues[attribute] = customAttributeRetrievalCallbacks[attribute].callback.call(
|
||||
rule,
|
||||
metadataInstance,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
readValues[attribute] = fromSrc[from]?.[attribute] ?? instance?.[attribute];
|
||||
}
|
||||
|
||||
// handle cases where the constraint is also a custom attribute
|
||||
const resolvedConstraint = resolveConstraintAttributes(
|
||||
readValues,
|
||||
rule.constraint,
|
||||
customAttributeRetrievalCallbacks,
|
||||
fromSrc
|
||||
);
|
||||
|
||||
// Format the constraint as required by Validate.js
|
||||
const testConstraint = {
|
||||
[attribute]: resolvedConstraint,
|
||||
};
|
||||
|
||||
// Create a single attribute object to be validated, since metadataInstance is an
|
||||
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
|
||||
const attributeMap = {
|
||||
[attribute]: readValues[attribute],
|
||||
};
|
||||
|
||||
// Use Validate.js to evaluate the constraints on the specified metadataInstance
|
||||
let errorMessages;
|
||||
try {
|
||||
errorMessages = validate(attributeMap, testConstraint, [validateOptions]);
|
||||
} catch (e) {
|
||||
errorMessages = ['Something went wrong during validation.', e];
|
||||
}
|
||||
|
||||
// TODO: move to a logger
|
||||
// console.log(
|
||||
// 'Test',
|
||||
// `${from}.${attribute}`,
|
||||
// readValues[attribute],
|
||||
// JSON.stringify(rule.constraint),
|
||||
// !errorMessages
|
||||
// );
|
||||
|
||||
if (!errorMessages) {
|
||||
// If no errorMessages were returned, then validation passed.
|
||||
|
||||
// Add the rule's weight to the total score
|
||||
score += parseInt(rule.weight || 1, 10);
|
||||
// Log that this rule passed in the matching details object
|
||||
details.passed.push({
|
||||
rule,
|
||||
});
|
||||
} else {
|
||||
// If errorMessages were present, then validation failed
|
||||
|
||||
// If the rule that failed validation was Required, then
|
||||
// mark that a required Rule has failed
|
||||
if (rule.required) {
|
||||
requiredFailed = true;
|
||||
}
|
||||
|
||||
// Log that this rule failed in the matching details object
|
||||
// and include any error messages
|
||||
details.failed.push({
|
||||
rule,
|
||||
errorMessages,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If a required Rule has failed Validation, set the matching score to zero
|
||||
if (requiredFailed) {
|
||||
score = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
score,
|
||||
details,
|
||||
requiredFailed,
|
||||
};
|
||||
};
|
||||
|
||||
// New helper function to resolve constraint attributes
|
||||
const resolveConstraintAttributes = (
|
||||
readValues,
|
||||
constraint,
|
||||
customAttributeRetrievalCallbacks,
|
||||
fromSrc
|
||||
) => {
|
||||
if (typeof constraint !== 'object' || constraint === null) {
|
||||
return constraint;
|
||||
}
|
||||
|
||||
const resolvedConstraint = {};
|
||||
Object.entries(constraint).forEach(([key, value]) => {
|
||||
if (typeof value === 'object' && Object.keys(value).length > 0 && 'attribute' in value) {
|
||||
const attributeName = value.attribute;
|
||||
const attributeFrom = value.from ?? 'metadataInstance';
|
||||
|
||||
if (customAttributeRetrievalCallbacks.hasOwnProperty(attributeName)) {
|
||||
const value = customAttributeRetrievalCallbacks[attributeName].callback.call(
|
||||
null,
|
||||
fromSrc[attributeFrom],
|
||||
fromSrc.options
|
||||
);
|
||||
|
||||
resolvedConstraint[key] = {
|
||||
value,
|
||||
};
|
||||
} else {
|
||||
resolvedConstraint[key] = {
|
||||
value:
|
||||
fromSrc[attributeFrom]?.[attributeName] ?? fromSrc.metadataInstance?.[attributeName],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
resolvedConstraint[key] = value;
|
||||
}
|
||||
}, {});
|
||||
|
||||
return resolvedConstraint;
|
||||
};
|
||||
|
||||
const HPMatcher = {
|
||||
match,
|
||||
};
|
||||
|
||||
export { HPMatcher };
|
||||
@@ -0,0 +1,192 @@
|
||||
import HangingProtocolService from './HangingProtocolService';
|
||||
|
||||
const testProtocol = {
|
||||
id: 'test',
|
||||
name: 'Default',
|
||||
protocolMatchingRules: [
|
||||
{
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: 'PETCT',
|
||||
},
|
||||
},
|
||||
],
|
||||
displaySetSelectors: {
|
||||
displaySetSelector: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: 'CT',
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'numImageFrames',
|
||||
constraint: {
|
||||
greaterThan: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
name: 'default',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
customViewportOptions: {
|
||||
initialScale: 2.5,
|
||||
},
|
||||
initialImageOptions: {
|
||||
// index: 5,
|
||||
preset: 'first', // 'first', 'last', 'middle'
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'cameraPosition',
|
||||
id: 'axialSync',
|
||||
source: true,
|
||||
target: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'displaySetSelector',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
};
|
||||
|
||||
function testProtocolGenerator({ servicesManager }) {
|
||||
servicesManager.services.TestService.toCall();
|
||||
|
||||
return {
|
||||
protocol: testProtocol,
|
||||
};
|
||||
}
|
||||
|
||||
const studyMatch = {
|
||||
StudyInstanceUID: 'studyMatch',
|
||||
StudyDescription: 'A PETCT study type',
|
||||
};
|
||||
|
||||
const displaySet1 = {
|
||||
...studyMatch,
|
||||
SeriesInstanceUID: 'ds1',
|
||||
displaySetInstanceUID: 'displaySet1',
|
||||
numImageFrames: 11,
|
||||
Modality: 'CT',
|
||||
};
|
||||
|
||||
const displaySet2 = {
|
||||
...displaySet1,
|
||||
SeriesInstanceUID: 'ds2',
|
||||
displaySetInstanceUID: 'displaySet2',
|
||||
Modality: 'PT',
|
||||
};
|
||||
|
||||
const displaySet3 = {
|
||||
...displaySet1,
|
||||
numImageFrames: 3,
|
||||
displaySetInstanceUID: 'displaySet3',
|
||||
};
|
||||
|
||||
const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1];
|
||||
|
||||
function checkHpsBestMatch(hps) {
|
||||
hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets });
|
||||
const { viewportMatchDetails } = hps.getMatchDetails();
|
||||
expect(viewportMatchDetails.size).toBe(1);
|
||||
expect(viewportMatchDetails.get('ctAXIAL')).toMatchObject({
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAXIAL',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
toolGroupId: 'ctToolGroup',
|
||||
},
|
||||
// Matches ds1 because it matches 2 rules, a required and an optional
|
||||
// ds2 fails to match required and ds3 fails to match an optional.
|
||||
displaySetsInfo: [
|
||||
{
|
||||
displaySetInstanceUID: 'displaySet1',
|
||||
displaySetOptions: {
|
||||
id: 'displaySetSelector',
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('HangingProtocolService', () => {
|
||||
const mockedFunction = jest.fn();
|
||||
const commandsManager = {
|
||||
run: mockedFunction,
|
||||
};
|
||||
const servicesManager = {
|
||||
services: {
|
||||
TestService: {
|
||||
toCall: mockedFunction,
|
||||
},
|
||||
},
|
||||
};
|
||||
const hangingProtocolService = new HangingProtocolService(commandsManager, servicesManager);
|
||||
let initialScaling;
|
||||
|
||||
afterEach(() => {
|
||||
mockedFunction.mockClear();
|
||||
});
|
||||
|
||||
describe('with a static protocol', () => {
|
||||
beforeAll(() => {
|
||||
hangingProtocolService.addProtocol(testProtocol.id, testProtocol);
|
||||
});
|
||||
|
||||
it('has one protocol', () => {
|
||||
expect(hangingProtocolService.getProtocols().length).toBe(1);
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('matches best image match', () => {
|
||||
checkHpsBestMatch(hangingProtocolService);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with protocol generator', () => {
|
||||
beforeAll(() => {
|
||||
hangingProtocolService.addProtocol(testProtocol.id, testProtocolGenerator);
|
||||
});
|
||||
|
||||
it('has one protocol', () => {
|
||||
expect(hangingProtocolService.getProtocols().length).toBe(1);
|
||||
});
|
||||
|
||||
describe('run', () => {
|
||||
it('matches best image match', () => {
|
||||
checkHpsBestMatch(hangingProtocolService);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
import { HPMatcher } from './HPMatcher.js';
|
||||
import { sortByScore } from './lib/sortByScore';
|
||||
|
||||
export default class ProtocolEngine {
|
||||
constructor(protocols, customAttributeRetrievalCallbacks) {
|
||||
this.protocols = protocols;
|
||||
this.customAttributeRetrievalCallbacks = customAttributeRetrievalCallbacks;
|
||||
this.matchedProtocols = new Map();
|
||||
this.matchedProtocolScores = {};
|
||||
this.study = undefined;
|
||||
}
|
||||
|
||||
/** Evaluate the hanging protocol matches on the given:
|
||||
* @param props.studies is a list of studies to compare against (for priors evaluation)
|
||||
* @param props.activeStudy is the current metadata for the study to display.
|
||||
* @param props.displaySets are the list of display sets which can be modified.
|
||||
*/
|
||||
run({ studies, displaySets, activeStudy }) {
|
||||
this.studies = studies;
|
||||
this.study = activeStudy || studies[0];
|
||||
this.displaySets = displaySets;
|
||||
return this.getBestProtocolMatch();
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Resets the ProtocolEngine to the best match
|
||||
// */
|
||||
// reset() {
|
||||
// const protocol = this.getBestProtocolMatch();
|
||||
|
||||
// this.setHangingProtocol(protocol);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Return the best matched Protocol to the current study or set of studies
|
||||
* @returns {*}
|
||||
*/
|
||||
getBestProtocolMatch() {
|
||||
// Run the matching to populate matchedProtocols Set and Map
|
||||
this.updateProtocolMatches();
|
||||
|
||||
// Retrieve the highest scoring Protocol
|
||||
const bestMatch = this._getHighestScoringProtocol();
|
||||
|
||||
console.log('ProtocolEngine::getBestProtocolMatch bestMatch', bestMatch);
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the MatchedProtocols Collection by running the matching procedure
|
||||
*/
|
||||
updateProtocolMatches() {
|
||||
console.log('ProtocolEngine::updateProtocolMatches');
|
||||
|
||||
// Clear all data currently in matchedProtocols
|
||||
this._clearMatchedProtocols();
|
||||
|
||||
// TODO: handle more than one study - this.studies has the list of studies
|
||||
const matched = this.findMatchByStudy(this.study, {
|
||||
studies: this.studies,
|
||||
displaySets: this.displaySets,
|
||||
});
|
||||
|
||||
// For each matched protocol, check if it is already in MatchedProtocols
|
||||
matched.forEach(matchedDetail => {
|
||||
const protocol = matchedDetail.protocol;
|
||||
if (!protocol) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is not already in the MatchedProtocols Collection, insert it with its score
|
||||
if (!this.matchedProtocols.has(protocol.id)) {
|
||||
console.log(
|
||||
'ProtocolEngine::updateProtocolMatches inserting protocol match',
|
||||
matchedDetail
|
||||
);
|
||||
this.matchedProtocols.set(protocol.id, protocol);
|
||||
this.matchedProtocolScores[protocol.id] = matchedDetail.score;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* finds the match results against the given display set or
|
||||
* study instance by testing the given rules against this, and using
|
||||
* the provided options for testing.
|
||||
*
|
||||
* @param {*} metaData to match against as primary value
|
||||
* @param {*} rules to apply
|
||||
* @param {*} options are additional values that can be used for matching
|
||||
* @returns
|
||||
*/
|
||||
findMatch(metaData, rules, options) {
|
||||
return HPMatcher.match(metaData, rules, this.customAttributeRetrievalCallbacks, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the best protocols from Protocol Store, matching each protocol matching rules
|
||||
* with the given study. The best protocol are ordered by score and returned in an array
|
||||
* @param {Object} study StudyMetadata instance object
|
||||
* @param {object} options containing additional matching data.
|
||||
* @return {Array} Array of match objects or an empty array if no match was found
|
||||
* Each match object has the score of the matching and the matched
|
||||
* protocol
|
||||
*/
|
||||
findMatchByStudy(study, options) {
|
||||
const matched = [];
|
||||
|
||||
this.protocols.forEach(protocol => {
|
||||
// Clone the protocol's protocolMatchingRules array
|
||||
// We clone it so that we don't accidentally add the
|
||||
// numberOfPriorsReferenced rule to the Protocol itself.
|
||||
let rules = protocol.protocolMatchingRules.slice();
|
||||
if (!rules || !rules.length) {
|
||||
console.warn(
|
||||
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules for',
|
||||
protocol.id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run the matcher and get matching details
|
||||
const matchedDetails = this.findMatch(study, rules, options);
|
||||
const score = matchedDetails.score;
|
||||
|
||||
// The protocol matched some rule, add it to the matched list
|
||||
if (score > 0) {
|
||||
matched.push({
|
||||
score,
|
||||
protocol,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If no matches were found, select the default protocol if provided
|
||||
// if not select the first protocol in the list
|
||||
if (!matched.length) {
|
||||
const protocol =
|
||||
this.protocols.find(protocol => protocol.id === 'default') ?? this.protocols[0];
|
||||
console.log('No protocol matches, defaulting to', protocol);
|
||||
return [
|
||||
{
|
||||
score: 0,
|
||||
protocol,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Sort the matched list by score
|
||||
sortByScore(matched);
|
||||
|
||||
console.log('ProtocolEngine::findMatchByStudy matched', matched);
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
_clearMatchedProtocols() {
|
||||
this.matchedProtocols.clear();
|
||||
this.matchedProtocolScores = {};
|
||||
}
|
||||
|
||||
_largestKeyByValue(obj) {
|
||||
return Object.keys(obj).reduce((a, b) => (obj[a] > obj[b] ? a : b));
|
||||
}
|
||||
|
||||
_getHighestScoringProtocol() {
|
||||
if (!Object.keys(this.matchedProtocolScores).length) {
|
||||
return;
|
||||
}
|
||||
const highestScoringProtocolId = this._largestKeyByValue(this.matchedProtocolScores);
|
||||
return this.matchedProtocols.get(highestScoringProtocolId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getSplitParam } from '../../../utils';
|
||||
|
||||
/** Indicates if the given display set is the one specified in the
|
||||
* displaySet parameter in the URL
|
||||
* The parameters are:
|
||||
* initialSeriesInstanceUID
|
||||
* initialSOPInstanceUID
|
||||
*/
|
||||
const isDisplaySetFromUrl = (displaySet): boolean => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initialSeriesInstanceUID = getSplitParam('initialseriesinstanceuid', params);
|
||||
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
|
||||
if (!initialSeriesInstanceUID && !initialSOPInstanceUID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isSeriesMatch = initialSeriesInstanceUID?.some(
|
||||
seriesUID => displaySet.SeriesInstanceUID === seriesUID
|
||||
);
|
||||
|
||||
const isSopMatch = initialSOPInstanceUID?.some(sopUID =>
|
||||
displaySet.instances?.some(instance => sopUID === instance.SOPInstanceUID)
|
||||
);
|
||||
|
||||
return isSeriesMatch || isSopMatch;
|
||||
};
|
||||
|
||||
/** Returns the index location of the requested image, or the defaultValue in this.
|
||||
* Returns undefined to fallback to the defaultValue
|
||||
*/
|
||||
function sopInstanceLocation(displaySets) {
|
||||
const displaySet = displaySets?.[0];
|
||||
if (!displaySet) {
|
||||
return;
|
||||
}
|
||||
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid');
|
||||
if (!initialSOPInstanceUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = displaySet.instances.findIndex(instance =>
|
||||
initialSOPInstanceUID.includes(instance.SOPInstanceUID)
|
||||
);
|
||||
// Need to return in the initial position specified format.
|
||||
return index === -1 ? undefined : { index };
|
||||
}
|
||||
|
||||
export { isDisplaySetFromUrl, sopInstanceLocation };
|
||||
@@ -0,0 +1,5 @@
|
||||
export default (study, extraData) => {
|
||||
const ret = extraData?.displaySets?.filter(ds => ds.numImageFrames > 0)?.length;
|
||||
console.log('number of display sets with images', ret);
|
||||
return ret;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription);
|
||||
@@ -0,0 +1,356 @@
|
||||
// This can be calculated by some formula probably, but for now we just use a constant since
|
||||
// this might be objective
|
||||
const GRID_MAPPINGS = {
|
||||
// 1x2
|
||||
'1x2:1x1': {
|
||||
0: 0,
|
||||
},
|
||||
'1x2:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:2x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:3x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:3x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x2:3x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
// 1x3
|
||||
'1x3:1x1': {
|
||||
0: 0,
|
||||
},
|
||||
'1x3:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x3:2x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'1x3:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'1x3:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'1x3:3x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'1x3:3x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'1x3:3x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
// 2x1
|
||||
'2x1:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'2x1:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'2x1:2x2': {
|
||||
0: 0,
|
||||
2: 1,
|
||||
},
|
||||
'2x1:2x3': {
|
||||
0: 0,
|
||||
3: 1,
|
||||
},
|
||||
'2x1:3x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'2x1:3x2': {
|
||||
0: 0,
|
||||
2: 1,
|
||||
},
|
||||
'2x1:3x3': {
|
||||
0: 0,
|
||||
3: 1,
|
||||
},
|
||||
// 2x2
|
||||
'2x2:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'2x2:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'2x2:2x1': {
|
||||
0: 0,
|
||||
1: 2,
|
||||
},
|
||||
'2x2:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
3: 2,
|
||||
4: 3,
|
||||
},
|
||||
'2x2:3x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'2x2:3x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
},
|
||||
'2x2:3x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
3: 2,
|
||||
4: 3,
|
||||
},
|
||||
// 2x3
|
||||
'2x3:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'2x3:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'2x3:2x1': {
|
||||
0: 0,
|
||||
1: 3,
|
||||
},
|
||||
'2x3:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 3,
|
||||
3: 4,
|
||||
},
|
||||
'2x3:3x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'2x3:3x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 4,
|
||||
5: 5,
|
||||
},
|
||||
'2x3:3x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 4,
|
||||
5: 5,
|
||||
},
|
||||
// 3x1
|
||||
'3x1:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'3x1:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'3x1:2x1': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
// TODO: I'm not sure about the following
|
||||
'3x1:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'3x1:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'3x1:3x2': {
|
||||
0: 0,
|
||||
2: 1,
|
||||
4: 2,
|
||||
},
|
||||
'3x1:3x3': {
|
||||
0: 0,
|
||||
3: 1,
|
||||
6: 2,
|
||||
},
|
||||
// 3x2
|
||||
'3x2:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'3x2:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'3x2:2x1': {
|
||||
0: 0,
|
||||
1: 2,
|
||||
},
|
||||
'3x2:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
},
|
||||
'3x2:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 4,
|
||||
5: 5,
|
||||
},
|
||||
'3x2:3x1': {
|
||||
0: 0,
|
||||
1: 2,
|
||||
2: 4,
|
||||
},
|
||||
'3x2:3x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
3: 2,
|
||||
4: 3,
|
||||
6: 4,
|
||||
7: 5,
|
||||
},
|
||||
// 3x3
|
||||
'3x3:1x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
},
|
||||
'3x3:1x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
},
|
||||
'3x3:2x1': {
|
||||
0: 0,
|
||||
1: 3,
|
||||
},
|
||||
'3x3:2x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 3,
|
||||
3: 4,
|
||||
},
|
||||
'3x3:2x3': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 3,
|
||||
4: 4,
|
||||
5: 5,
|
||||
},
|
||||
'3x3:3x1': {
|
||||
0: 0,
|
||||
1: 3,
|
||||
2: 6,
|
||||
},
|
||||
'3x3:3x2': {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 3,
|
||||
3: 4,
|
||||
4: 6,
|
||||
5: 7,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The purpose of this function is to convert a grid with numRows and numCols
|
||||
* and index for each cell into another grid with different dimensions, but it should
|
||||
* intelligently use the data from the original grid to fill the new grid at
|
||||
* correct locations.
|
||||
*
|
||||
* For instance:
|
||||
* if the old grid is a 2x2 (numRows = 2, numCols = 2) and the new grid is a 3x3
|
||||
* it should intelligently insert the cells in the new grid so that the cells
|
||||
* are added to the right most column. Then the mapping is as follows:
|
||||
* 0 -> 0, 1 -> 1, 3 -> 2, 4 -> 3 (viewport 2 in the old grid can be used in
|
||||
* the place of viewport 3 in the new grid)
|
||||
*
|
||||
* Or if the old grid is 2x2 and new grid is 2x4, the mapping is as follows:
|
||||
* 0 -> 0, 1 -> 1, 4 -> 2 and 5 -> 3
|
||||
*
|
||||
* Or if the old grid is 2x2 and the new grid is 1x2, the mapping is as follows:
|
||||
* 0 -> 0, 1 -> 2
|
||||
*
|
||||
* @param {Object} oldGrid
|
||||
* @param {number} oldGrid.numRows
|
||||
* @param {number} oldGrid.numCols
|
||||
*
|
||||
* @param {Object} newGrid
|
||||
* @param {number} newGrid.numRows
|
||||
* @param {number} newGrid.numCols
|
||||
*
|
||||
* @returns {Map} A map that maps the new indices to the old indices
|
||||
*
|
||||
*/
|
||||
const getGridMapping = (oldGrid, newGrid) => {
|
||||
const mapping = {};
|
||||
const { numRows: oldNumRows, numCols: oldNumCols } = oldGrid;
|
||||
const { numRows: newNumRows, numCols: newNumCols } = newGrid;
|
||||
|
||||
if (oldNumRows === 1 && oldNumCols === 1) {
|
||||
// If the old grid is 1x1, then we can just return the first cell
|
||||
mapping[0] = 0;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
if (newNumRows === 1 && newNumCols === 1) {
|
||||
// If the new grid is 1x1, then we can just return the first cell
|
||||
mapping[0] = 0;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
const key = `${oldNumRows}x${oldNumCols}:${newNumRows}x${newNumCols}`;
|
||||
const map = GRID_MAPPINGS[key];
|
||||
|
||||
if (!map) {
|
||||
throw new Error(`No mapping found for ${key}`);
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
export default getGridMapping;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HangingProtocolService from './HangingProtocolService';
|
||||
|
||||
export default HangingProtocolService;
|
||||
@@ -0,0 +1,98 @@
|
||||
const comparators = [
|
||||
{
|
||||
id: 'equals',
|
||||
name: '= (Equals)',
|
||||
validator: 'equals',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must equal this value.',
|
||||
},
|
||||
{
|
||||
id: 'doesNotEqual',
|
||||
name: '!= (Does not equal)',
|
||||
validator: 'doesNotEqual',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must not equal this value.',
|
||||
},
|
||||
{
|
||||
id: 'contains',
|
||||
name: 'Contains',
|
||||
validator: 'contains',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must contain this value.',
|
||||
},
|
||||
{
|
||||
id: 'doesNotContain',
|
||||
name: 'Does not contain',
|
||||
validator: 'doesNotContain',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must not contain this value.',
|
||||
},
|
||||
{
|
||||
id: 'startsWith',
|
||||
name: 'Starts with',
|
||||
validator: 'startsWith',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must start with this value.',
|
||||
},
|
||||
{
|
||||
id: 'endsWith',
|
||||
name: 'Ends with',
|
||||
validator: 'endsWith',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must end with this value.',
|
||||
},
|
||||
{
|
||||
id: 'onlyInteger',
|
||||
name: 'Only Integers',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'onlyInteger',
|
||||
description: "Real numbers won't be allowed.",
|
||||
},
|
||||
{
|
||||
id: 'greaterThan',
|
||||
name: '> (Greater than)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'greaterThan',
|
||||
description: 'The attribute has to be greater than this value.',
|
||||
},
|
||||
{
|
||||
id: 'greaterThanOrEqualTo',
|
||||
name: '>= (Greater than or equal to)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'greaterThanOrEqualTo',
|
||||
description: 'The attribute has to be at least this value.',
|
||||
},
|
||||
{
|
||||
id: 'lessThanOrEqualTo',
|
||||
name: '<= (Less than or equal to)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'lessThanOrEqualTo',
|
||||
description: 'The attribute can be this value at the most.',
|
||||
},
|
||||
{
|
||||
id: 'lessThan',
|
||||
name: '< (Less than)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'lessThan',
|
||||
description: 'The attribute has to be less than this value.',
|
||||
},
|
||||
{
|
||||
id: 'odd',
|
||||
name: 'Odd',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'odd',
|
||||
description: 'The attribute has to be odd.',
|
||||
},
|
||||
{
|
||||
id: 'even',
|
||||
name: 'Even',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'even',
|
||||
description: 'The attribute has to be even.',
|
||||
},
|
||||
];
|
||||
|
||||
// Immutable object
|
||||
Object.freeze(comparators);
|
||||
|
||||
export { comparators };
|
||||
@@ -0,0 +1,70 @@
|
||||
const attributeCache = Object.create(null);
|
||||
const REGEXP = /^\([x0-9a-f]+\)/;
|
||||
|
||||
const humanize = text => {
|
||||
let humanized = text.replace(/([A-Z])/g, ' $1'); // insert a space before all caps
|
||||
|
||||
humanized = humanized.replace(/^./, str => {
|
||||
// uppercase the first character
|
||||
return str.toUpperCase();
|
||||
});
|
||||
|
||||
return humanized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the text of an attribute for a given attribute
|
||||
* @param {String} attributeId The attribute ID
|
||||
* @param {Array} attributes Array of attributes objects with id and text properties
|
||||
* @return {String} If found return the attribute text or an empty string otherwise
|
||||
*/
|
||||
const getAttributeText = (attributeId, attributes) => {
|
||||
// If the attribute is already in the cache, return it
|
||||
if (attributeId in attributeCache) {
|
||||
return attributeCache[attributeId];
|
||||
}
|
||||
|
||||
// Find the attribute with given attributeId
|
||||
const attribute = attributes.find(attribute => attribute.id === attributeId);
|
||||
|
||||
let attributeText;
|
||||
|
||||
// If attribute was found get its text and save it on the cache
|
||||
if (attribute) {
|
||||
attributeText = attribute.text.replace(REGEXP, '');
|
||||
attributeCache[attributeId] = attributeText;
|
||||
}
|
||||
|
||||
return attributeText || '';
|
||||
};
|
||||
|
||||
function displayConstraint(attributeId, constraint, attributes) {
|
||||
if (!constraint || !attributeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatorType = Object.keys(constraint)[0];
|
||||
if (!validatorType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validator = Object.keys(constraint[validatorType])[0];
|
||||
if (!validator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = constraint[validatorType][validator];
|
||||
if (value === void 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let comparator = validator;
|
||||
if (validator === 'value') {
|
||||
comparator = validatorType;
|
||||
}
|
||||
|
||||
const attributeText = getAttributeText(attributeId, attributes);
|
||||
const constraintText = attributeText + ' ' + humanize(comparator).toLowerCase() + ' ' + value;
|
||||
|
||||
return constraintText;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Removes the first instance of an element from an array, if an equal value exists
|
||||
*
|
||||
* @param array
|
||||
* @param input
|
||||
*
|
||||
* @returns {boolean} Whether or not the element was found and removed
|
||||
*/
|
||||
const removeFromArray = (array, input) => {
|
||||
// If the array is empty, stop here
|
||||
if (!array || !array.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
array.forEach((value, index) => {
|
||||
// TODO: Double check whether or not this deep equality check is necessary
|
||||
//if (_.isEqual(value, input)) {
|
||||
if (value === input) {
|
||||
indexToRemove = index;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (indexToRemove === void 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
array.splice(indexToRemove, 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
export { removeFromArray };
|
||||
@@ -0,0 +1,8 @@
|
||||
// Sorts an array by score
|
||||
const sortByScore = arr => {
|
||||
arr.sort((a, b) => {
|
||||
return b.score - a.score;
|
||||
});
|
||||
};
|
||||
|
||||
export { sortByScore };
|
||||
@@ -0,0 +1,463 @@
|
||||
import validate from 'validate.js';
|
||||
/**
|
||||
* check if the value is strictly equal to options
|
||||
*
|
||||
* @example
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'abc' (Fail)
|
||||
* = ['abc'] (Fail)
|
||||
* = ['abc', 'def', 'GHI'] (Valid)
|
||||
* = ['abc', 'GHI', 'def'] (Fail)
|
||||
* = ['abc', 'def'] (Fail)
|
||||
*
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = 'Attenuation Corrected' (Valid)
|
||||
* testValue = 'Attenuation' (Fail)
|
||||
*
|
||||
* value = ['Attenuation Corrected']
|
||||
* testValue = ['Attenuation Corrected'] (Valid)
|
||||
* = 'Attenuation Corrected' (Valid)
|
||||
* = 'Attenuation' (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.equals = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
const dicomArrayValue = dicomTagToArray(value);
|
||||
|
||||
// If options is an array, then we need to validate each element in the array
|
||||
if (Array.isArray(testValue)) {
|
||||
// If the array has only one element, then we need to compare the value to that element
|
||||
if (testValue.length !== dicomArrayValue.length) {
|
||||
return `${key} must be an array of length ${testValue.length}`;
|
||||
} else {
|
||||
for (let i = 0; i < testValue.length; i++) {
|
||||
if (testValue[i] !== dicomArrayValue[i]) {
|
||||
return `${key} ${testValue[i]} must equal ${dicomArrayValue[i]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (testValue !== dicomArrayValue[0]) {
|
||||
return `${key} must equal ${testValue}`;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* check if the value is not equal to options
|
||||
*
|
||||
* @example
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'abc' (Valid)
|
||||
* = ['abc'] (Valid)
|
||||
* = ['abc', 'def', 'GHI'] (Fail)
|
||||
* = ['abc', 'GHI', 'def'] (Valid)
|
||||
* = ['abc', 'def'] (Valid)
|
||||
*
|
||||
* value = 'Attenuation Corrected'
|
||||
* = 'Attenuation Corrected' (Fail)
|
||||
* = 'Attenuation' (Valid)
|
||||
*
|
||||
* value = ['Attenuation Corrected']
|
||||
* testValue = ['Attenuation Corrected'] (Fail)
|
||||
* = 'Attenuation Corrected' (Fail)
|
||||
* = 'Attenuation' (Fail)
|
||||
* */
|
||||
validate.validators.doesNotEqual = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
const dicomArrayValue = dicomTagToArray(value);
|
||||
|
||||
if (Array.isArray(testValue)) {
|
||||
if (testValue.length === dicomArrayValue.length) {
|
||||
let score = 0;
|
||||
testValue.forEach((x, i) => {
|
||||
if (x === dicomArrayValue[i]) {
|
||||
score++;
|
||||
}
|
||||
});
|
||||
if (score === testValue.length) {
|
||||
return `${key} must not equal to ${testValue}`;
|
||||
}
|
||||
}
|
||||
} else if (testValue === dicomArrayValue[0]) {
|
||||
console.log(dicomArrayValue, testValue);
|
||||
return `${key} must not equal to ${testValue}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a value includes one or more specified options.
|
||||
*
|
||||
* @example
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = ‘abc’ (Fail)
|
||||
* = ‘dog’ (Fail)
|
||||
* = [‘abc’] (Valid)
|
||||
* = [‘att’, ‘abc’] (Valid)
|
||||
* = ['abc', 'def', 'dog'] (Valid)
|
||||
* = ['cat', 'dog'] (Fail)
|
||||
*
|
||||
* value = ['Attenuation Corrected']
|
||||
* testValue = 'Attenuation Corrected' (Fail)
|
||||
* = ['Attenuation Corrected', 'Corrected'] (Valid)
|
||||
* = ['Attenuation', 'Corrected'] (Fail)
|
||||
*
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ['Attenuation Corrected', 'Corrected'] (Valid)
|
||||
* = ['Attenuation', 'Corrected'] (Fail)
|
||||
* */
|
||||
validate.validators.includes = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
const dicomArrayValue = dicomTagToArray(value);
|
||||
|
||||
if (Array.isArray(testValue)) {
|
||||
const includedValues = testValue.filter(el => dicomArrayValue.includes(el));
|
||||
if (includedValues.length === 0) {
|
||||
return `${key} must include at least one of the following values: ${testValue.join(', ')}`;
|
||||
}
|
||||
} else {
|
||||
return `${key} ${testValue} must be an array`;
|
||||
}
|
||||
// else if (!value.includes(testValue)) {
|
||||
// return `${key} ${value} must include ${testValue}`;
|
||||
// }
|
||||
};
|
||||
/**
|
||||
* Check if a value does not include one or more specified options.
|
||||
*
|
||||
* @example
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = ['Corr'] (Valid)
|
||||
* = 'abc' (Fail)
|
||||
* = ['abc'] (Fail)
|
||||
* = [‘att’, ‘cor’] (Valid)
|
||||
* = ['abc', 'def', 'dog'] (Fail)
|
||||
*
|
||||
* value = ['Attenuation Corrected']
|
||||
* testValue = 'Attenuation Corrected' (Fail)
|
||||
* = ['Attenuation Corrected', 'Corrected'] (Fail)
|
||||
* = ['Attenuation', 'Corrected'] (Valid)
|
||||
*
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ['Attenuation Corrected', 'Corrected'] (Fail)
|
||||
* = ['Attenuation', 'Corrected'] (Valid)
|
||||
* */
|
||||
validate.validators.doesNotInclude = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
const dicomArrayValue = dicomTagToArray(value);
|
||||
|
||||
// if (!Array.isArray(value) || value.length === 1) {
|
||||
// return `${key} is not allowed as a single value`;
|
||||
// }
|
||||
if (Array.isArray(testValue)) {
|
||||
const includedValues = testValue.filter(el => dicomArrayValue.includes(el));
|
||||
if (includedValues.length > 0) {
|
||||
return `${key} must not include the following value: ${includedValues}`;
|
||||
}
|
||||
} else {
|
||||
return `${key} ${testValue} must be an array`;
|
||||
}
|
||||
};
|
||||
// Ignore case contains.
|
||||
// options testValue MUST be in lower case already, otherwise it won't match
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘Corr’ (Valid)
|
||||
* = ‘corr’ (Valid)
|
||||
* = [‘att’, ‘cor’] (Valid)
|
||||
* = [‘Att’, ‘Wall’] (Valid)
|
||||
* = [‘cat’, ‘dog’] (Fail)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'def' (Valid)
|
||||
* = 'dog' (Fail)
|
||||
* = ['gh', 'de'] (Valid)
|
||||
* = ['cat', 'dog'] (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.containsI = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.some(item => !validate.validators.containsI(item.toLowerCase(), options, key))) {
|
||||
return undefined;
|
||||
}
|
||||
return `No item of ${value.join(',')} contains ${JSON.stringify(testValue)}`;
|
||||
}
|
||||
if (Array.isArray(testValue)) {
|
||||
if (
|
||||
testValue.some(subTest => !validate.validators.containsI(value, subTest.toLowerCase(), key))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return `${key} must contain at least one of ${testValue.join(',')}`;
|
||||
}
|
||||
if (testValue && value.indexOf && value.toLowerCase().indexOf(testValue.toLowerCase()) === -1) {
|
||||
return key + 'must contain any case of' + testValue;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘Corr’ (Valid)
|
||||
* = ‘corr’ (Fail)
|
||||
* = [‘att’, ‘cor’] (Fail)
|
||||
* = [‘Att’, ‘Wall’] (Valid)
|
||||
* = [‘cat’, ‘dog’] (Fail)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'def' (Valid)
|
||||
* = 'dog' (Fail)
|
||||
* = ['cat', 'de'] (Valid)
|
||||
* = ['cat', 'dog'] (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.contains = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.some(item => !validate.validators.contains(item, options, key))) {
|
||||
return undefined;
|
||||
}
|
||||
return `No item of ${value.join(',')} contains ${JSON.stringify(testValue)}`;
|
||||
}
|
||||
if (Array.isArray(testValue)) {
|
||||
if (testValue.some(subTest => !validate.validators.contains(value, subTest, key))) {
|
||||
return;
|
||||
}
|
||||
return `${key} must contain at least one of ${testValue.join(',')}`;
|
||||
}
|
||||
if (testValue && value.indexOf && value.indexOf(testValue) === -1) {
|
||||
return key + 'must contain ' + testValue;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘Corr’ (Fail)
|
||||
* = ‘corr’ (Valid)
|
||||
* = [‘att’, ‘cor’] (Valid)
|
||||
* = [‘Att’, ‘Wall’] (Fail)
|
||||
* = [‘cat’, ‘dog’] (Valid)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'def' (Fail)
|
||||
* = 'dog' (Valid)
|
||||
* = ['cat', 'de'] (Fail)
|
||||
* = ['cat', 'dog'] (Valid)
|
||||
*
|
||||
* */
|
||||
validate.validators.doesNotContain = function (value, options, key) {
|
||||
const containsResult = validate.validators.contains(value, options, key);
|
||||
if (!containsResult) {
|
||||
return `No item of ${value} should contain ${getTestValue(options)}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘Corr’ (Fail)
|
||||
* = ‘corr’ (Fail)
|
||||
* = [‘att’, ‘cor’] (Fail)
|
||||
* = [‘Att’, ‘Wall’] (Fail)
|
||||
* = [‘cat’, ‘dog’] (Valid)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'DEF' (Fail)
|
||||
* = 'dog' (Valid)
|
||||
* = ['cat', 'gh'] (Fail)
|
||||
* = ['cat', 'dog'] (Valid)
|
||||
*
|
||||
* */
|
||||
validate.validators.doesNotContainI = function (value, options, key) {
|
||||
const containsResult = validate.validators.containsI(value, options, key);
|
||||
if (!containsResult) {
|
||||
return `No item of ${value} should not contain ${getTestValue(options)}`;
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘Corr’ (Fail)
|
||||
* = ‘Att’ (Fail)
|
||||
* = ['cat', 'dog', 'Att'] (Valid)
|
||||
* = [‘cat’, ‘dog’] (Fail)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'deg' (Valid)
|
||||
* = ['cat', 'GH'] (Valid)
|
||||
* = ['cat', 'gh'] (Fail)
|
||||
* = ['cat', 'dog'] (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.startsWith = function (value, options, key) {
|
||||
let testValues = getTestValue(options);
|
||||
|
||||
if (typeof testValues === 'string') {
|
||||
testValues = [testValues];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (!testValues.some(testValue => value.startsWith(testValue))) {
|
||||
return key + ' must start with any of these values: ' + testValues;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
let valid = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
for (let j = 0; j < testValues.length; j++) {
|
||||
if (value[i].startsWith(testValues[j])) {
|
||||
valid = true; // set valid flag to true if a match is found
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
return undefined; // break out of loop if a match is found
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
return key + ' must start with any of these values: ' + testValues; // return undefined if no match is found
|
||||
}
|
||||
} else {
|
||||
return 'Value must be a string or an array';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @example
|
||||
* value = 'Attenuation Corrected'
|
||||
* testValue = ‘TED’ (Fail)
|
||||
* = ‘ted’ (Valid)
|
||||
* = ['cat', 'dog', 'ted'] (Valid)
|
||||
* = [‘cat’, ‘dog’] (Fail)
|
||||
*
|
||||
* value = ['abc', 'def', 'GHI']
|
||||
* testValue = 'deg' (Valid)
|
||||
* = ['cat', 'HI'] (Valid)
|
||||
* = ['cat', 'hi'] (Fail)
|
||||
* = ['cat', 'dog'] (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.endsWith = function (value, options, key) {
|
||||
let testValues = getTestValue(options);
|
||||
|
||||
if (typeof testValues === 'string') {
|
||||
testValues = [testValues];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (!testValues.some(testValue => value.endsWith(testValue))) {
|
||||
return key + ' must end with any of these values: ' + testValues;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
let valid = false;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
for (let j = 0; j < testValues.length; j++) {
|
||||
if (value[i].endsWith(testValues[j])) {
|
||||
valid = true; // set valid flag to true if a match is found
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
return undefined; // break out of loop if a match is found
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
return key + ' must end with any of these values: ' + testValues; // return undefined if no match is found
|
||||
}
|
||||
} else {
|
||||
return key + ' must be a string or an array';
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
* value = 30
|
||||
* testValue = 20 (Valid)
|
||||
* = 40 (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.greaterThan = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
if (Array.isArray(value) || typeof value === 'string') {
|
||||
return `${key} is not allowed as an array or string`;
|
||||
}
|
||||
if (Array.isArray(testValue)) {
|
||||
if (testValue.length === 1) {
|
||||
if (!(value >= testValue[0])) {
|
||||
return `${key} must be greater than or equal to ${testValue[0]}, but was ${value}`;
|
||||
}
|
||||
} else if (testValue.length > 1) {
|
||||
return key + ' must be an array of length 1';
|
||||
}
|
||||
} else {
|
||||
if (!(value >= testValue)) {
|
||||
return key + ' must be greater than ' + testValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
* value = 30
|
||||
* testValue = 40 (Valid)
|
||||
* = 20 (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.lessThan = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
if (Array.isArray(testValue)) {
|
||||
if (testValue.length === 1) {
|
||||
if (!(value <= testValue[0])) {
|
||||
return `${key} must be less than or equal to ${testValue[0]}, but was ${value}`;
|
||||
}
|
||||
} else if (testValue.length > 1) {
|
||||
return key + ' must be an array of length 1';
|
||||
}
|
||||
} else {
|
||||
if (!(value <= testValue)) {
|
||||
return key + ' must be less than ' + testValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
/**
|
||||
* @example
|
||||
|
||||
*
|
||||
* value = 50
|
||||
* testValue = [10,60] (Valid)
|
||||
* = [60, 10] (Valid)
|
||||
* = [0, 10] (Fail)
|
||||
* = [70, 80] (Fail)
|
||||
* = 45 (Fail)
|
||||
* = [45] (Fail)
|
||||
*
|
||||
* */
|
||||
validate.validators.range = function (value, options, key) {
|
||||
const testValue = getTestValue(options);
|
||||
if (Array.isArray(testValue) && testValue.length === 2) {
|
||||
const min = Math.min(testValue[0], testValue[1]);
|
||||
const max = Math.max(testValue[0], testValue[1]);
|
||||
if (value === undefined || value < min || value > max) {
|
||||
return `${key} with value ${value} must be between ${min} and ${max}`;
|
||||
}
|
||||
} else {
|
||||
return `${key} must be an array of length 2`;
|
||||
}
|
||||
};
|
||||
|
||||
validate.validators.notNull = value =>
|
||||
value === null || value === undefined ? 'Value is null' : undefined;
|
||||
const getTestValue = options => {
|
||||
if (Array.isArray(options)) {
|
||||
return options.map(option => option?.value ?? option);
|
||||
} else {
|
||||
return options?.value ?? options;
|
||||
}
|
||||
};
|
||||
const dicomTagToArray = value => {
|
||||
let dicomArrayValue;
|
||||
if (!Array.isArray(value)) {
|
||||
dicomArrayValue = [value];
|
||||
} else {
|
||||
dicomArrayValue = [...value];
|
||||
}
|
||||
return dicomArrayValue;
|
||||
};
|
||||
export default validate;
|
||||
@@ -0,0 +1,358 @@
|
||||
import validate from './validator.js';
|
||||
|
||||
describe('validator', () => {
|
||||
const attributeMap = {
|
||||
str: 'Attenuation Corrected',
|
||||
upper: 'UPPER',
|
||||
num: 3,
|
||||
nullValue: null,
|
||||
list: ['abc', 'def', 'GHI'],
|
||||
listStr: ['Attenuation Corrected'],
|
||||
};
|
||||
|
||||
const options = {
|
||||
format: 'grouped',
|
||||
};
|
||||
|
||||
describe('equals', () => {
|
||||
it('returned undefined on strictly equals', () => {
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { equals: ['Attenuation'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { equals: 'Attenuation' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { equals: 'Attenuation Corrected' } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { equals: ['Attenuation Corrected'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { equals: 'Attenuation Corrected' } }, [options])
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
validate(attributeMap, { str: { equals: { value: 'Attenuation Corrected' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { equals: ['Attenuation Corrected'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { equals: ['Attenuation'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { equals: ['abc', 'def', 'GHI'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { equals: ['abc', 'GHI', 'def'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { equals: { value: ['abc', 'def', 'GHI'] } } }, [options])
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('doesNotEqual', () => {
|
||||
it('returns undefined if value does not equal ', () => {
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { doesNotEqual: 'Attenuation Corrected' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { doesNotEqual: ['Attenuation Corrected'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { doesNotEqual: 'Attenuation' } }, [options])
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotEqual: 'Attenuation' } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotEqual: { value: 'Attenuation' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotEqual: ['Attenuation'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotEqual: ['abc', 'def'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotEqual: ['abc', 'GHI', 'def'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotEqual: ['abc', 'def', 'GHI'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('includes', () => {
|
||||
it('returns match any list includes', () => {
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { includes: 'Attenuation Corrected' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { includes: ['Attenuation Corrected'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { includes: ['Attenuation Corrected', 'Corrected'] } }, [
|
||||
options,
|
||||
])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { includes: ['Attenuation', 'Corrected'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { includes: ['Attenuation Corrected', 'Corrected'] } }, [
|
||||
options,
|
||||
])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { includes: ['Attenuation', 'Corrected'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { includes: ['abc'] } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { includes: ['GHI', 'HI'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { includes: ['HI', 'bye'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('doesNotInclude', () => {
|
||||
it('returns undefined if list does not includes', () => {
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { doesNotInclude: 'Attenuation Corrected' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(
|
||||
attributeMap,
|
||||
{
|
||||
listStr: { doesNotInclude: ['Attenuation Corrected', 'Corrected'] },
|
||||
},
|
||||
[options]
|
||||
)
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { listStr: { doesNotInclude: ['Attenuation', 'Corrected'] } }, [
|
||||
options,
|
||||
])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(
|
||||
attributeMap,
|
||||
{ str: { doesNotInclude: ['Attenuation Corrected', 'Corrected'] } },
|
||||
[options]
|
||||
)
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotInclude: ['Attenuation', 'Corrected'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotInclude: ['Corr'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotInclude: 'abc' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotInclude: { value: ['abc'] } } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotInclude: { value: ['att', 'cor'] } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotInclude: { value: ['abc', 'def', 'dog'] } } }, [
|
||||
options,
|
||||
])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('containsI', () => {
|
||||
it('returns match any list contains case insensitive', () => {
|
||||
expect(
|
||||
validate(attributeMap, { upper: { containsI: ['hi', 'pre'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { containsI: 'hi' } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { containsI: ['ghi', 'bye'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { containsI: ['bye', 'hi'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { containsI: ['ig', 'hi'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { upper: { containsI: ['bye', 'per'] } }, [options])
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('contains', () => {
|
||||
it('returns match any list contains', () => {
|
||||
expect(validate(attributeMap, { str: { contains: 'Corr' } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { contains: { value: 'Corr' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { str: { contains: ['Corr'] } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { contains: ['corr'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { contains: ['Att', 'Wall'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { contains: 'GH' } }, [options])).toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { contains: ['ab'] } }, [options])).toBeUndefined();
|
||||
|
||||
expect(
|
||||
validate(attributeMap, { list: { contains: ['z', 'bc'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { contains: ['z'] } }, [options])).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesNotContain', () => {
|
||||
it('returns undefined if string does not contain specified value', () => {
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: ['att', 'wall'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: 'Corr' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: 'corr' } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: { value: 'corr' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: ['att', 'cor'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: ['Att', 'cor'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContain: ['bye', 'hi'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotContain: ['GHI', 'hi'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotContain: ['hi'] } }, [options])
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('doesNotContainI', () => {
|
||||
it('returns undefined if string does not contain specified value', () => {
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContainI: 'corr' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContainI: 'Corr' } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContainI: ['att', 'cor'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContainI: ['Att', 'wall'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { doesNotContainI: ['bye', 'hi'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotContainI: ['bye', 'ABC'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotContainI: 'bye' } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { doesNotContainI: ['bye', 'ABC'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('startsWith', () => {
|
||||
it('returns undefined if string starts with specified value', () => {
|
||||
expect(
|
||||
validate(attributeMap, { str: { startsWith: { value: 'Atte' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { str: { startsWith: 'Att' } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { startsWith: ['cat', 'dog', 'Att'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { startsWith: ['cat', 'dog'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { startsWith: ['GH'] } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { startsWith: ['de', 'bye'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { startsWith: ['hi', 'bye'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('endsWith', () => {
|
||||
it('returns undefined if string ends with specified value', () => {
|
||||
expect(validate(attributeMap, { str: { endsWith: 'ted' } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { endsWith: { value: 'ted' } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { str: { endsWith: ['ted'] } }, [options])).toBeUndefined();
|
||||
expect(validate(attributeMap, { str: { endsWith: ['Att'] } }, [options])).not.toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { str: { endsWith: ['cat', 'dog', 'ted'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { list: { endsWith: ['HI'] } }, [options])).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { endsWith: ['bc', 'dog', 'ted'] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { list: { endsWith: ['bye', 'dog'] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('greaterThan', () => {
|
||||
it('returns undefined on greaterThan', () => {
|
||||
expect(
|
||||
validate(attributeMap, { num: { greaterThan: { value: attributeMap.num - 1 } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { greaterThan: attributeMap.num - 1 } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { greaterThan: [attributeMap.num - 1] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { greaterThan: [attributeMap.num + 1] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('lessThan', () => {
|
||||
it('returns undefined on lessThan', () => {
|
||||
expect(
|
||||
validate(attributeMap, { num: { lessThan: { value: attributeMap.num + 1 } } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { lessThan: attributeMap.num + 1 } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { lessThan: [attributeMap.num + 1] } }, [options])
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validate(attributeMap, { num: { lessThan: [attributeMap.num - 1] } }, [options])
|
||||
).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('range', () => {
|
||||
it('returns undefined if the value is between', () => {
|
||||
expect(
|
||||
validate(attributeMap, { num: { range: [attributeMap.num + 1, attributeMap.num - 1] } }, [
|
||||
options,
|
||||
])
|
||||
).toBeUndefined();
|
||||
expect(validate(attributeMap, { num: { range: [1, 4] } }, [options])).toBeUndefined();
|
||||
expect(validate(attributeMap, { num: { range: [1, 2] } }, [options])).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { num: { range: [4, 5] } }, [options])).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { num: { range: [5] } }, [options])).not.toBeUndefined();
|
||||
expect(validate(attributeMap, { num: { range: 5 } }, [options])).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import MeasurementService from './MeasurementService';
|
||||
import log from '../../log';
|
||||
|
||||
jest.mock('../../log', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MeasurementService.js', () => {
|
||||
const unmappedMeasurementUID = 'unmappedMeasurementUId';
|
||||
let measurementService;
|
||||
let measurement;
|
||||
let unmappedMeasurement;
|
||||
let source;
|
||||
let annotationType;
|
||||
let matchingCriteria;
|
||||
let toSourceSchema;
|
||||
let toMeasurement;
|
||||
let toMeasurementThrowsError;
|
||||
let annotation;
|
||||
|
||||
beforeEach(() => {
|
||||
measurementService = new MeasurementService();
|
||||
source = measurementService.createSource('Test', '1');
|
||||
annotationType = 'Length';
|
||||
annotation = {
|
||||
toolName: annotationType,
|
||||
measurementData: {},
|
||||
};
|
||||
measurement = {
|
||||
SOPInstanceUID: '123',
|
||||
FrameOfReferenceUID: '1234',
|
||||
referenceSeriesUID: '12345',
|
||||
label: 'Label',
|
||||
description: 'Description',
|
||||
unit: 'mm',
|
||||
area: 123,
|
||||
type: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
source: source,
|
||||
};
|
||||
// A measurement with various metadata missing (e.g. referenced SOPInstanceUID) that
|
||||
// would not typically get mapped my the MeasurementService possibly because it was
|
||||
// made in a non-acquisition plane of a volume.
|
||||
unmappedMeasurement = {
|
||||
uid: unmappedMeasurementUID,
|
||||
SOPInstanceUID: undefined,
|
||||
FrameOfReferenceUID: undefined,
|
||||
referenceSeriesUID: undefined,
|
||||
label: 'Label',
|
||||
description: 'Description',
|
||||
unit: 'mm',
|
||||
area: 123,
|
||||
type: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
source: source,
|
||||
};
|
||||
toSourceSchema = () => annotation;
|
||||
toMeasurement = () => {
|
||||
if (Object.keys(measurement).includes('invalidProperty')) {
|
||||
throw new Error('Measurement does not match schema');
|
||||
}
|
||||
|
||||
return measurement;
|
||||
};
|
||||
toMeasurementThrowsError = () => {
|
||||
throw new Error('Unmapped measurement.');
|
||||
};
|
||||
matchingCriteria = {
|
||||
valueType: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: 2,
|
||||
};
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createSource()', () => {
|
||||
it('creates new source with name and version', () => {
|
||||
measurementService.createSource('Testing', '1');
|
||||
});
|
||||
|
||||
it('throws Error if no name provided', () => {
|
||||
expect(() => {
|
||||
measurementService.createSource(null, '1');
|
||||
}).toThrow(new Error('Source name not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no version provided', () => {
|
||||
expect(() => {
|
||||
measurementService.createSource('Testing', null);
|
||||
}).toThrow(new Error('Source version not provided.'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMapping()', () => {
|
||||
it('adds new mapping', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
});
|
||||
|
||||
it('throws Error if invalid source provided', () => {
|
||||
expect(() => {
|
||||
const invalidSource = {};
|
||||
|
||||
measurementService.addMapping(
|
||||
invalidSource,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
}).toThrow(new Error('Invalid source.'));
|
||||
});
|
||||
|
||||
it('throws Error if no matching criteria provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(source, annotationType, null, toSourceSchema, toMeasurement);
|
||||
}).toThrow(new Error('Matching criteria not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no source provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
null /* source */,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
}).toThrow(new Error('Invalid source.'));
|
||||
});
|
||||
|
||||
it('logs warning and return early if no AnnotationType provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
null /* AnnotationType */,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
}).toThrow(new Error('annotationType not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no measurement mapping function provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
null /* toSourceSchema */,
|
||||
toMeasurement
|
||||
);
|
||||
}).toThrow(new Error('Mapping function to source schema not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no annotation mapping function provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
null /* toMeasurement */
|
||||
);
|
||||
}).toThrow(new Error('Measurement mapping function not provided.'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnnotation()', () => {
|
||||
it('get annotation based on matched criteria', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
const measurementId = source.annotationToMeasurement(annotationType, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(annotationType, measurementId);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
|
||||
it('get annotation based on source and annotationType', () => {
|
||||
measurementService.addMapping(source, annotationType, {}, toSourceSchema, toMeasurement);
|
||||
const measurementId = source.annotationToMeasurement(annotationType, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(annotationType, measurementId);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeasurements()', () => {
|
||||
it('return all measurement service measurements', () => {
|
||||
const anotherMeasurement = {
|
||||
...measurement,
|
||||
label: 'Label2',
|
||||
unit: 'HU',
|
||||
};
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, anotherMeasurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
expect(measurements.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMeasurement()', () => {
|
||||
it('return measurement service measurement with given id', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
const returnedMeasurement = measurementService.getMeasurement(uid);
|
||||
|
||||
/* Clear dynamic data */
|
||||
delete returnedMeasurement.modifiedTimestamp;
|
||||
|
||||
expect({ uid, ...measurement }).toEqual(returnedMeasurement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotationToMeasurement()', () => {
|
||||
it('adds new measurements', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
expect(measurements.length).toBe(2);
|
||||
});
|
||||
|
||||
it('fails to add new measurements when no mapping', () => {
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('fails to add new measurements when invalid mapping function', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
1 /* Invalid */
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('adds new measurement with custom uid', () => {
|
||||
const newMeasurement = { uid: 1, ...measurement };
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
/* Add new measurement */
|
||||
source.annotationToMeasurement(annotationType, newMeasurement);
|
||||
const savedMeasurement = measurementService.getMeasurement(newMeasurement.uid);
|
||||
|
||||
/* Clear dynamic data */
|
||||
delete newMeasurement.modifiedTimestamp;
|
||||
delete savedMeasurement.modifiedTimestamp;
|
||||
|
||||
expect(newMeasurement).toEqual(savedMeasurement);
|
||||
});
|
||||
|
||||
it('throws Error if adding invalid measurement', () => {
|
||||
measurement.invalidProperty = {};
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('throws Error if adding measurement with unknown schema key', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
() => {
|
||||
return {
|
||||
...measurement,
|
||||
invalidSchemaKey: 0,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('updates existing measurement', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
measurement.unit = 'HU';
|
||||
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement });
|
||||
const updatedMeasurement = measurementService.getMeasurement(uid);
|
||||
|
||||
expect(updatedMeasurement.unit).toBe('HU');
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscribe()', () => {
|
||||
it('subscribers receive broadcasted add event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
|
||||
let addCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to add event */
|
||||
measurementService.subscribe(MEASUREMENT_ADDED, () => (addCallbackWasCalled = true));
|
||||
|
||||
/* Add new measurement - two calls needed for the start and the other for the completed*/
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement });
|
||||
|
||||
expect(addCallbackWasCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('subscribers receive broadcasted update event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const { MEASUREMENT_UPDATED } = measurementService.EVENTS;
|
||||
let updateCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to update event */
|
||||
measurementService.subscribe(MEASUREMENT_UPDATED, () => (updateCallbackWasCalled = true));
|
||||
|
||||
/* Create measurement */
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
/* Update measurement */
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement }, true);
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('unsubscribes a listener', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
let updateCallbackWasCalled = false;
|
||||
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
|
||||
|
||||
/* Subscribe to Add event */
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MEASUREMENT_ADDED,
|
||||
() => (updateCallbackWasCalled = true)
|
||||
);
|
||||
|
||||
/* Unsubscribe */
|
||||
unsubscribe();
|
||||
|
||||
/* Create measurement - two calls needed one to start and one to complete */
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement });
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('subscribers do NOT receive add unmapped measurements event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurementThrowsError
|
||||
);
|
||||
|
||||
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
|
||||
let addCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to add event */
|
||||
measurementService.subscribe(MEASUREMENT_ADDED, () => (addCallbackWasCalled = true));
|
||||
|
||||
/* Add new measurement - two calls needed for the start and the other for the completed*/
|
||||
// expect exceptions for unmapped measurements
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, unmappedMeasurement);
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, {
|
||||
unmappedMeasurementUID,
|
||||
...unmappedMeasurement,
|
||||
});
|
||||
}).toThrow();
|
||||
|
||||
expect(addCallbackWasCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('subscribers do receive remove unmapped measurements event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurementThrowsError
|
||||
);
|
||||
|
||||
const { MEASUREMENT_REMOVED } = measurementService.EVENTS;
|
||||
let removeCallbackWasCalled = false;
|
||||
|
||||
/* Subscribe to add event */
|
||||
measurementService.subscribe(MEASUREMENT_REMOVED, () => (removeCallbackWasCalled = true));
|
||||
|
||||
/* Add new measurement - two calls needed for the start and the other for the completed*/
|
||||
// expect exceptions for unmapped measurements
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, unmappedMeasurement);
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
source.annotationToMeasurement(annotationType, {
|
||||
unmappedMeasurementUID,
|
||||
...unmappedMeasurement,
|
||||
});
|
||||
}).toThrow();
|
||||
|
||||
measurementService.remove(unmappedMeasurementUID);
|
||||
|
||||
expect(removeCallbackWasCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,792 @@
|
||||
import log from '../../log';
|
||||
import guid from '../../utils/guid';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
|
||||
/**
|
||||
* Measurement source schema
|
||||
*
|
||||
* @typedef {Object} MeasurementSource
|
||||
* @property {number} id -
|
||||
* @property {string} name -
|
||||
* @property {string} version -
|
||||
*/
|
||||
|
||||
/**
|
||||
* Measurement schema
|
||||
*
|
||||
* @typedef {Object} Measurement
|
||||
* @property {number} uid -
|
||||
* @property {string} SOPInstanceUID -
|
||||
* @property {string} FrameOfReferenceUID -
|
||||
* @property {string} referenceSeriesUID -
|
||||
* @property {string} label -
|
||||
* @property {string} description -
|
||||
* @property {string} type -
|
||||
* @property {string} unit -
|
||||
* @property {number} area -
|
||||
* @property {Array} points -
|
||||
* @property {MeasurementSource} source -
|
||||
* @property {boolean} selected -
|
||||
*/
|
||||
|
||||
/* Measurement schema keys for object validation. */
|
||||
const MEASUREMENT_SCHEMA_KEYS = [
|
||||
'uid',
|
||||
'color',
|
||||
'data',
|
||||
'getReport',
|
||||
'displayText',
|
||||
'SOPInstanceUID',
|
||||
'FrameOfReferenceUID',
|
||||
'referenceStudyUID',
|
||||
'referenceSeriesUID',
|
||||
'frameNumber',
|
||||
'displaySetInstanceUID',
|
||||
'label',
|
||||
'isLocked',
|
||||
'isVisible',
|
||||
'description',
|
||||
'type',
|
||||
'unit',
|
||||
'points',
|
||||
'source',
|
||||
'toolName',
|
||||
'metadata',
|
||||
// Todo: we shouldn't need to have all these here.
|
||||
'area', // TODO: Add concept names instead (descriptor)
|
||||
'mean',
|
||||
'stdDev',
|
||||
'perimeter',
|
||||
'length',
|
||||
'shortestDiameter',
|
||||
'longestDiameter',
|
||||
'cachedStats',
|
||||
'isSelected',
|
||||
'textBox',
|
||||
'referencedImageId',
|
||||
];
|
||||
|
||||
const EVENTS = {
|
||||
MEASUREMENT_UPDATED: 'event::measurement_updated',
|
||||
INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated',
|
||||
MEASUREMENT_ADDED: 'event::measurement_added',
|
||||
RAW_MEASUREMENT_ADDED: 'event::raw_measurement_added',
|
||||
MEASUREMENT_REMOVED: 'event::measurement_removed',
|
||||
MEASUREMENTS_CLEARED: 'event::measurements_cleared',
|
||||
// Give the viewport a chance to jump to the measurement
|
||||
JUMP_TO_MEASUREMENT_VIEWPORT: 'event:jump_to_measurement_viewport',
|
||||
// Give the layout a chance to jump to the measurement
|
||||
JUMP_TO_MEASUREMENT_LAYOUT: 'event:jump_to_measurement_layout',
|
||||
};
|
||||
|
||||
const VALUE_TYPES = {
|
||||
ANGLE: 'value_type::polyline',
|
||||
POLYLINE: 'value_type::polyline',
|
||||
POINT: 'value_type::point',
|
||||
BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here.
|
||||
ELLIPSE: 'value_type::ellipse',
|
||||
RECTANGLE: 'value_type::rectangle',
|
||||
MULTIPOINT: 'value_type::multipoint',
|
||||
CIRCLE: 'value_type::circle',
|
||||
ROI_THRESHOLD: 'value_type::roiThreshold',
|
||||
ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual',
|
||||
};
|
||||
|
||||
/**
|
||||
* MeasurementService class that supports source management and measurement management.
|
||||
* Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.)
|
||||
* The flow, is that by creating a source and mappings (annotation <-> measurement), we
|
||||
* can convert back and forth between the two. MeasurementPanel in OHIF uses the measurement service
|
||||
* to manage the measurements, and any edit to the measurements will be reflected back at the
|
||||
* library level state (e.g. cornerstone-tools, cornerstone, etc.) by converting the
|
||||
* edited measurements back to the original annotations and then updating the annotations.
|
||||
*
|
||||
* Note and Todo: We should be able to support measurements that are composed of multiple
|
||||
* annotations, but that is not the case at the moment.
|
||||
*/
|
||||
class MeasurementService extends PubSubService {
|
||||
public static REGISTRATION = {
|
||||
name: 'measurementService',
|
||||
altName: 'MeasurementService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new MeasurementService();
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly EVENTS = EVENTS;
|
||||
public static VALUE_TYPES = VALUE_TYPES;
|
||||
public readonly VALUE_TYPES = VALUE_TYPES;
|
||||
|
||||
private measurements = new Map();
|
||||
private unmappedMeasurements = new Map();
|
||||
|
||||
constructor() {
|
||||
super(EVENTS);
|
||||
this.sources = {};
|
||||
this.mappings = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given schema to the measurement service schema list.
|
||||
* This method should be used to add custom tool schema to the measurement service.
|
||||
* @param {Array} schema schema for validation
|
||||
*/
|
||||
public addMeasurementSchemaKeys(schema): void {
|
||||
if (!Array.isArray(schema)) {
|
||||
schema = [schema];
|
||||
}
|
||||
|
||||
MEASUREMENT_SCHEMA_KEYS.push(...schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given valueType to the measurement service valueType object.
|
||||
* This method should be used to add custom valueType to the measurement service.
|
||||
* @param {*} valueType
|
||||
* @returns
|
||||
*/
|
||||
addValueType(valueType) {
|
||||
if (VALUE_TYPES[valueType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if valuetype is valid , and if values are strings
|
||||
if (!valueType || typeof valueType !== 'object') {
|
||||
console.warn(`MeasurementService: addValueType: invalid valueType: ${valueType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(valueType).forEach(key => {
|
||||
if (!VALUE_TYPES[key]) {
|
||||
VALUE_TYPES[key] = valueType[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all measurements.
|
||||
*
|
||||
* @return {Measurement[]} Array of measurements
|
||||
*/
|
||||
getMeasurements() {
|
||||
return [...this.measurements.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific measurement by its uid.
|
||||
*
|
||||
* @param {string} uid measurement uid
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
public getMeasurement(measurementUID: string) {
|
||||
return this.measurements.get(measurementUID);
|
||||
}
|
||||
|
||||
public setMeasurementSelected(measurementUID: string, selected: boolean): void {
|
||||
const measurement = this.getMeasurement(measurementUID);
|
||||
if (!measurement) {
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.isSelected = selected;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement,
|
||||
notYetUpdatedAtSource: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new source.
|
||||
*
|
||||
* @param {string} name Name of the source
|
||||
* @param {string} version Source name
|
||||
* @return {MeasurementSource} Measurement source instance
|
||||
*/
|
||||
createSource(name, version) {
|
||||
if (!name) {
|
||||
throw new Error('Source name not provided.');
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
throw new Error('Source version not provided.');
|
||||
}
|
||||
|
||||
// Go over all the keys inside the sources and check if the source
|
||||
// name and version matches with the existing sources.
|
||||
const sourceKeys = Object.keys(this.sources);
|
||||
|
||||
for (let i = 0; i < sourceKeys.length; i++) {
|
||||
const source = this.sources[sourceKeys[i]];
|
||||
if (source.name === name && source.version === version) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
const uid = guid();
|
||||
const source = {
|
||||
uid,
|
||||
name,
|
||||
version,
|
||||
};
|
||||
|
||||
source.annotationToMeasurement = (annotationType, annotation, isUpdate = false) => {
|
||||
return this.annotationToMeasurement(source, annotationType, annotation, isUpdate);
|
||||
};
|
||||
|
||||
source.remove = (measurementUID, eventDetails) => {
|
||||
return this.remove(measurementUID, source, eventDetails);
|
||||
};
|
||||
|
||||
source.getAnnotation = (annotationType, measurementId) => {
|
||||
return this.getAnnotation(source, annotationType, measurementId);
|
||||
};
|
||||
|
||||
log.info(`New '${name}@${version}' source added.`);
|
||||
this.sources[uid] = source;
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
getSource(name, version) {
|
||||
const { sources } = this;
|
||||
const uid = this._getSourceUID(name, version);
|
||||
|
||||
return sources[uid];
|
||||
}
|
||||
|
||||
getSourceMappings(name, version) {
|
||||
const { mappings } = this;
|
||||
const uid = this._getSourceUID(name, version);
|
||||
|
||||
return mappings[uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new measurement matching criteria along with mapping functions.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} annotationType annotation type to match which can be e.g., Length, Bidirectional, etc.
|
||||
* @param {MatchingCriteria} matchingCriteria The matching criteria
|
||||
* @param {Function} toAnnotationSchema Mapping function to annotation schema
|
||||
* @param {Function} toMeasurementSchema Mapping function to measurement schema
|
||||
* @return void
|
||||
*/
|
||||
addMapping(source, annotationType, matchingCriteria, toAnnotationSchema, toMeasurementSchema) {
|
||||
if (!this._isValidSource(source)) {
|
||||
throw new Error('Invalid source.');
|
||||
}
|
||||
|
||||
if (!matchingCriteria) {
|
||||
throw new Error('Matching criteria not provided.');
|
||||
}
|
||||
|
||||
if (!annotationType) {
|
||||
throw new Error('annotationType not provided.');
|
||||
}
|
||||
|
||||
if (!toAnnotationSchema) {
|
||||
throw new Error('Mapping function to source schema not provided.');
|
||||
}
|
||||
|
||||
if (!toMeasurementSchema) {
|
||||
throw new Error('Measurement mapping function not provided.');
|
||||
}
|
||||
|
||||
const mapping = {
|
||||
matchingCriteria,
|
||||
annotationType,
|
||||
toAnnotationSchema,
|
||||
toMeasurementSchema,
|
||||
};
|
||||
|
||||
if (Array.isArray(this.mappings[source.uid])) {
|
||||
this.mappings[source.uid].push(mapping);
|
||||
} else {
|
||||
this.mappings[source.uid] = [mapping];
|
||||
}
|
||||
|
||||
log.info(`New measurement mapping added to source '${this._getSourceToString(source)}'.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get annotation for specific source.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {string} measurementUID The measurement service measurement uid
|
||||
* @return {Object} Source measurement schema
|
||||
*/
|
||||
getAnnotation(source, annotationType, measurementUID) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!annotationType) {
|
||||
log.warn('No source annotationType provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement = this.getMeasurement(measurementUID);
|
||||
const mapping = this._getMappingByMeasurementSource(measurement, annotationType);
|
||||
|
||||
if (mapping) {
|
||||
return mapping.toAnnotationSchema(measurement, annotationType);
|
||||
}
|
||||
|
||||
const matchingMapping = this._getMatchingMapping(source, annotationType, measurement);
|
||||
|
||||
if (matchingMapping) {
|
||||
log.info('Matching mapping found:', matchingMapping);
|
||||
const { toAnnotationSchema, annotationType } = matchingMapping;
|
||||
return toAnnotationSchema(measurement, annotationType);
|
||||
}
|
||||
}
|
||||
|
||||
update(measurementUID: string, measurement, notYetUpdatedAtSource = false) {
|
||||
if (!this.measurements.has(measurementUID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
log.info(`Updating internal measurement representation...`, updatedMeasurement);
|
||||
|
||||
this.measurements.set(measurementUID, updatedMeasurement);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement: updatedMeasurement,
|
||||
notYetUpdatedAtSource,
|
||||
});
|
||||
|
||||
return updatedMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a raw measurement into a source so that it may be
|
||||
* Converted to/from annotation in the same way. E.g. import serialized data
|
||||
* of the same form as the measurement source.
|
||||
* @param {MeasurementSource} source The measurement source instance.
|
||||
* @param {string} annotationType The source annotationType you want to add the measurement to.
|
||||
* @param {object} data The data you wish to add to the source.
|
||||
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source annotationType.
|
||||
*/
|
||||
addRawMeasurement(source, annotationType, data, toMeasurementSchema, dataSource = {}) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceInfo = this._getSourceToString(source);
|
||||
|
||||
if (!annotationType) {
|
||||
log.warn('No source annotationType provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._sourceHasMappings(source)) {
|
||||
log.warn(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let measurement = {};
|
||||
try {
|
||||
measurement = toMeasurementSchema(data);
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
|
||||
error.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isValidMeasurement(measurement)) {
|
||||
log.warn(
|
||||
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let internalUID = data.id;
|
||||
if (!internalUID) {
|
||||
internalUID = guid();
|
||||
log.warn(`Measurement ID not found. Generating UID: ${internalUID}`);
|
||||
}
|
||||
|
||||
const annotationData = data.annotation.data;
|
||||
|
||||
const newMeasurement = {
|
||||
finding: annotationData.finding,
|
||||
findingSites: annotationData.findingSites,
|
||||
site: annotationData.findingSites?.[0],
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
uid: internalUID,
|
||||
};
|
||||
|
||||
if (this.measurements.get(internalUID)) {
|
||||
this.measurements.set(internalUID, newMeasurement);
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
});
|
||||
} else {
|
||||
log.info('Measurement added', newMeasurement);
|
||||
this.measurements.set(internalUID, newMeasurement);
|
||||
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
data,
|
||||
dataSource,
|
||||
});
|
||||
}
|
||||
|
||||
return newMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or update persisted measurements.
|
||||
*
|
||||
* @param {MeasurementSource} source The measurement source instance
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {EventDetail} sourceAnnotationDetail for the annotation event
|
||||
* @param {boolean} isUpdate is this an update or an add/completed instead?
|
||||
* @return {string} A measurement uid
|
||||
*/
|
||||
annotationToMeasurement(source, annotationType, sourceAnnotationDetail, isUpdate = false) {
|
||||
if (!this._isValidSource(source)) {
|
||||
throw new Error('Invalid source.');
|
||||
}
|
||||
if (!annotationType) {
|
||||
throw new Error('No source annotationType provided.');
|
||||
}
|
||||
|
||||
const sourceInfo = this._getSourceToString(source);
|
||||
if (!this._sourceHasMappings(source)) {
|
||||
throw new Error(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
|
||||
}
|
||||
|
||||
let measurement = {};
|
||||
try {
|
||||
const sourceMappings = this.mappings[source.uid];
|
||||
const sourceMapping = sourceMappings.find(
|
||||
mapping => mapping.annotationType === annotationType
|
||||
);
|
||||
if (!sourceMapping) {
|
||||
console.log('No source mapping', source);
|
||||
return;
|
||||
}
|
||||
const { toMeasurementSchema } = sourceMapping;
|
||||
|
||||
/* Convert measurement */
|
||||
measurement = toMeasurementSchema(sourceAnnotationDetail);
|
||||
if (!measurement) {
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
// Todo: handle other
|
||||
this.unmappedMeasurements.set(sourceAnnotationDetail.uid, {
|
||||
...sourceAnnotationDetail,
|
||||
source: {
|
||||
name: source.name,
|
||||
version: source.version,
|
||||
uid: source.uid,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Failed to map', error);
|
||||
throw new Error(
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._isValidMeasurement(measurement)) {
|
||||
throw new Error(
|
||||
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: we are using uid on the eventDetail, it should be uid of annotation
|
||||
let internalUID = sourceAnnotationDetail.uid;
|
||||
if (!internalUID) {
|
||||
internalUID = guid();
|
||||
log.info(
|
||||
`Annotation does not have UID, Generating UID for the created Measurement: ${internalUID}`
|
||||
);
|
||||
}
|
||||
|
||||
const oldMeasurement = this.measurements.get(internalUID);
|
||||
|
||||
const newMeasurement = {
|
||||
...oldMeasurement,
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
uid: internalUID,
|
||||
};
|
||||
|
||||
if (oldMeasurement) {
|
||||
// TODO: Ultimately, each annotation should have a selected flag right from the source.
|
||||
// For now, it is just added in OHIF here and in setMeasurementSelected.
|
||||
this.measurements.set(internalUID, newMeasurement);
|
||||
if (isUpdate) {
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
notYetUpdatedAtSource: false,
|
||||
});
|
||||
} else {
|
||||
log.info('Measurement added.', newMeasurement);
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info('Measurement started.', newMeasurement);
|
||||
this.measurements.set(internalUID, newMeasurement);
|
||||
}
|
||||
|
||||
return newMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a measurement and broadcasts the removed event.
|
||||
*
|
||||
* @param {string} measurementUID The measurement uid
|
||||
*/
|
||||
remove(measurementUID: string): void {
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
|
||||
|
||||
if (!measurementUID || !measurement) {
|
||||
console.debug(`No uid provided, or unable to find measurement by uid.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = measurement.source;
|
||||
|
||||
this.unmappedMeasurements.delete(measurementUID);
|
||||
this.measurements.delete(measurementUID);
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, {
|
||||
source,
|
||||
measurement: measurementUID,
|
||||
});
|
||||
}
|
||||
|
||||
clearMeasurements() {
|
||||
// Make a copy of the measurements
|
||||
const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()];
|
||||
this.unmappedMeasurements.clear();
|
||||
this.measurements.clear();
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the mode.onModeExit is called to reset the state.
|
||||
* To store measurements for later use, store them in the mode.onModeExit
|
||||
* and restore them in the mode onModeEnter.
|
||||
*/
|
||||
onModeExit() {
|
||||
this.clearMeasurements();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method calls the subscriptions for JUMP_TO_MEASUREMENT_VIEWPORT
|
||||
* and JUMP_TO_MEASUREMENT_LAYOUT. There are two events which are
|
||||
* fired because there are two different items which might want to handle
|
||||
* the event. First, there might already be a viewport which can handle
|
||||
* the event. If so, then the layout doesn't need to necessarily change.
|
||||
* This is communicated by the isConsumed value on the event itself.
|
||||
* Otherwise, the layout itself may need to be navigated to in order
|
||||
* to provide a viewport which can show the given measurement.
|
||||
*
|
||||
* When a viewport decides to apply the event, it should call the consume()
|
||||
* method on the event, so that other listeners know they do not need to
|
||||
* navigate. This does NOT affect whether the layout event is fired, and
|
||||
* merely causes it to fire the event with the isConsumed set to true.
|
||||
*/
|
||||
|
||||
public jumpToMeasurement(viewportId: string, measurementUID: string): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurement) {
|
||||
log.warn(`No measurement uid, or unable to find by uid.`);
|
||||
return;
|
||||
}
|
||||
const consumableEvent = this.createConsumableEvent({
|
||||
viewportId,
|
||||
measurement,
|
||||
});
|
||||
|
||||
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
|
||||
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, consumableEvent);
|
||||
}
|
||||
|
||||
_getSourceUID(name, version) {
|
||||
const { sources } = this;
|
||||
|
||||
const sourceUID = Object.keys(sources).find(sourceUID => {
|
||||
const source = sources[sourceUID];
|
||||
|
||||
return source.name === name && source.version === version;
|
||||
});
|
||||
|
||||
return sourceUID;
|
||||
}
|
||||
|
||||
_getMappingByMeasurementSource(measurement, annotationType) {
|
||||
if (this._isValidSource(measurement.source)) {
|
||||
return this.mappings[measurement.source.uid].find(m => m.annotationType === annotationType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get measurement mapping function if matching criteria.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {Measurement} measurement The measurement service measurement
|
||||
* @return {Object} The mapping based on matched criteria
|
||||
*/
|
||||
_getMatchingMapping(source, annotationType, measurement) {
|
||||
const sourceMappings = this.mappings[source.uid];
|
||||
|
||||
const sourceMappingsByDefinition = sourceMappings.filter(
|
||||
mapping => mapping.annotationType === annotationType
|
||||
);
|
||||
|
||||
/* Criteria Matching */
|
||||
return sourceMappingsByDefinition.find(({ matchingCriteria }) => {
|
||||
return measurement.points && measurement.points.length === matchingCriteria.points;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns formatted string with source info.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source
|
||||
* @return {string} Source information
|
||||
*/
|
||||
_getSourceToString(source) {
|
||||
return `${source.name}@${source.version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if given source is valid.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source
|
||||
* @return {boolean} Measurement source validation
|
||||
*/
|
||||
_isValidSource(source) {
|
||||
return source && this.sources[source.uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given source has mappings.
|
||||
*
|
||||
* @param {MeasurementSource} source The measurement source
|
||||
* @return {boolean} Validation if source has mappings
|
||||
*/
|
||||
_sourceHasMappings(source) {
|
||||
return Array.isArray(this.mappings[source.uid]) && this.mappings[source.uid].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given measurement data is valid.
|
||||
*
|
||||
* @param {Measurement} measurementData Measurement data
|
||||
* @return {boolean} Measurement validation
|
||||
*/
|
||||
_isValidMeasurement(measurementData) {
|
||||
return Object.keys(measurementData).every(key => {
|
||||
if (!MEASUREMENT_SCHEMA_KEYS.includes(key)) {
|
||||
log.warn(`Invalid measurement key: ${key}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given measurement service event is valid.
|
||||
*
|
||||
* @param {string} eventName The name of the event
|
||||
* @return {boolean} Event name validation
|
||||
// */
|
||||
// _isValidEvent(eventName) {
|
||||
// return Object.values(this.EVENTS).includes(eventName);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Converts object of objects to array.
|
||||
*
|
||||
* @return {Array} Array of objects
|
||||
*/
|
||||
_arrayOfObjects = obj => {
|
||||
return Object.entries(obj).map(e => ({ [e[0]]: e[1] }));
|
||||
};
|
||||
|
||||
public toggleLockMeasurement(measurementUID: string): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.isLocked = !measurement.isLocked;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement,
|
||||
notYetUpdatedAtSource: true,
|
||||
});
|
||||
}
|
||||
|
||||
public toggleVisibilityMeasurement(measurementUID: string): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.isVisible = !measurement.isVisible;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement,
|
||||
notYetUpdatedAtSource: true,
|
||||
});
|
||||
}
|
||||
|
||||
public updateColorMeasurement(measurementUID: string, color: number[]): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.color = color;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement,
|
||||
notYetUpdatedAtSource: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default MeasurementService;
|
||||
export { EVENTS, VALUE_TYPES };
|
||||
3
platform/core/src/services/MeasurementService/index.ts
Normal file
3
platform/core/src/services/MeasurementService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import MeasurementService from './MeasurementService';
|
||||
|
||||
export default MeasurementService;
|
||||
229
platform/core/src/services/PanelService/PanelService.tsx
Normal file
229
platform/core/src/services/PanelService/PanelService.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { ActivatePanelTriggers } from '../../types';
|
||||
import { Subscription } from '../../types/IPubSub';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import { ExtensionManager } from '../../extensions';
|
||||
|
||||
export const EVENTS = {
|
||||
PANELS_CHANGED: 'event::panelService:panelsChanged',
|
||||
ACTIVATE_PANEL: 'event::panelService:activatePanel',
|
||||
};
|
||||
|
||||
type PanelData = {
|
||||
id: string;
|
||||
iconName: string;
|
||||
iconLabel: string;
|
||||
label: string;
|
||||
name: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
export enum PanelPosition {
|
||||
Left = 'left',
|
||||
Right = 'right',
|
||||
Bottom = 'bottom',
|
||||
}
|
||||
|
||||
export default class PanelService extends PubSubService {
|
||||
private _extensionManager: ExtensionManager;
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'panelService',
|
||||
create: ({ extensionManager }): PanelService => {
|
||||
return new PanelService(extensionManager);
|
||||
},
|
||||
};
|
||||
|
||||
private _panelsGroups: Map<PanelPosition, PanelData[]> = new Map();
|
||||
|
||||
constructor(extensionManager: ExtensionManager) {
|
||||
super(EVENTS);
|
||||
this._extensionManager = extensionManager;
|
||||
}
|
||||
|
||||
public get PanelPosition(): typeof PanelPosition {
|
||||
return PanelPosition;
|
||||
}
|
||||
|
||||
private _getPanelComponent(panelId: string) {
|
||||
const entry = this._extensionManager.getModuleEntry(panelId);
|
||||
|
||||
if (!entry) {
|
||||
// Check for similar panel names
|
||||
const similarPanels = this._getSimilarPanels(panelId);
|
||||
|
||||
if (similarPanels.length > 0) {
|
||||
const suggestion = `Did you mean: ${similarPanels.join(', ')}?`;
|
||||
throw new Error(
|
||||
`${panelId} is not a valid entry for an extension module. ${suggestion} Please check your configuration or make sure the extension is registered.`
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`${panelId} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry?.component) {
|
||||
throw new Error(
|
||||
`No component found from extension ${panelId}. Check the reference string to the extension in your Mode configuration`
|
||||
);
|
||||
}
|
||||
|
||||
const content = entry.component;
|
||||
|
||||
return { entry, content };
|
||||
}
|
||||
|
||||
private _getSimilarPanels(panelId: string, threshold = 0.8): string[] {
|
||||
const registeredPanels = Object.keys(this._extensionManager.modulesMap).filter(name =>
|
||||
name.includes('panelModule')
|
||||
);
|
||||
|
||||
const similarPanels = registeredPanels.filter(registeredPanelId => {
|
||||
const similarity = this._calculateSimilarity(panelId, registeredPanelId);
|
||||
return similarity >= threshold;
|
||||
});
|
||||
|
||||
return similarPanels;
|
||||
}
|
||||
|
||||
private _calculateSimilarity(str1: string, str2: string): number {
|
||||
const set1 = new Set(str1.toLowerCase().split(''));
|
||||
const set2 = new Set(str2.toLowerCase().split(''));
|
||||
const intersection = new Set([...set1].filter(x => set2.has(x)));
|
||||
const union = new Set([...set1, ...set2]);
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
public getPanelData(panelId): PanelData {
|
||||
let content, entry;
|
||||
if (Array.isArray(panelId)) {
|
||||
const panelsData = panelId.map(id => this._getPanelComponent(id));
|
||||
|
||||
// use the first panel's entry for the combined panel
|
||||
entry = panelsData[0].entry;
|
||||
|
||||
// stack the content of the panels in one react component
|
||||
content = props => (
|
||||
<>
|
||||
{panelsData.map(({ content: PanelContent }, index) => (
|
||||
<PanelContent
|
||||
key={index}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
({ content, entry } = this._getPanelComponent(panelId));
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
iconName: entry.iconName,
|
||||
iconLabel: entry.iconLabel,
|
||||
label: entry.label,
|
||||
name: entry.name,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
public addPanel(position: PanelPosition, panelId: string, options): void {
|
||||
let panels = this._panelsGroups.get(position);
|
||||
|
||||
if (!panels) {
|
||||
panels = [];
|
||||
this._panelsGroups.set(position, panels);
|
||||
}
|
||||
|
||||
const panelComponent = this.getPanelData(panelId);
|
||||
|
||||
panels.push(panelComponent);
|
||||
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position, options });
|
||||
}
|
||||
|
||||
public addPanels(position: PanelPosition, panelsIds: string[], options): void {
|
||||
if (!Array.isArray(panelsIds)) {
|
||||
throw new Error('Invalid "panelsIds" array');
|
||||
}
|
||||
|
||||
panelsIds.forEach(panelId => this.addPanel(position, panelId, options));
|
||||
}
|
||||
|
||||
public setPanels(
|
||||
panels: { [key in PanelPosition]: string[] },
|
||||
options: {
|
||||
rightPanelClosed?: boolean;
|
||||
leftPanelClosed?: boolean;
|
||||
}
|
||||
): void {
|
||||
this.reset();
|
||||
|
||||
Object.keys(panels).forEach((position: PanelPosition) => {
|
||||
this.addPanels(position, panels[position], options);
|
||||
});
|
||||
}
|
||||
|
||||
public getPanels(position: PanelPosition): PanelData[] {
|
||||
const panels = this._panelsGroups.get(position) ?? [];
|
||||
|
||||
// Return a new array to preserve the internal state
|
||||
return [...panels];
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const affectedPositions = Array.from(this._panelsGroups.keys());
|
||||
|
||||
this._panelsGroups.clear();
|
||||
|
||||
affectedPositions.forEach(position =>
|
||||
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position })
|
||||
);
|
||||
}
|
||||
|
||||
public onModeExit(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**5
|
||||
* Activates the panel with the given id. If the forceActive flag is false
|
||||
* then it is up to the component containing the panel whether to activate
|
||||
* it immediately or not. For instance, the panel might not be activated when
|
||||
* the forceActive flag is false in the case where the user might have
|
||||
* activated/displayed and then closed the panel already.
|
||||
* Note that this method simply fires a broadcast event: ActivatePanelEvent.
|
||||
* @param panelId the panel's id
|
||||
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
|
||||
*/
|
||||
public activatePanel(panelId: string, forceActive = false): void {
|
||||
this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by
|
||||
* activatePanelTrigger.sourcePubSubService that
|
||||
* when fired/broadcasted must in turn activate the panel with the given id.
|
||||
* The subscriptions created are returned such that they can be managed and unsubscribed
|
||||
* as appropriate.
|
||||
* @param panelId the id of the panel to activate
|
||||
* @param activatePanelTriggers an array of triggers
|
||||
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
|
||||
* @returns an array of the subscriptions subscribed to
|
||||
*/
|
||||
public addActivatePanelTriggers(
|
||||
panelId: string,
|
||||
activatePanelTriggers: ActivatePanelTriggers[],
|
||||
forceActive = false
|
||||
): Subscription[] {
|
||||
return activatePanelTriggers
|
||||
.map(trigger =>
|
||||
trigger.sourceEvents.map(eventName =>
|
||||
trigger.sourcePubSubService.subscribe(eventName, () =>
|
||||
this.activatePanel(panelId, forceActive)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/PanelService/index.ts
Normal file
3
platform/core/src/services/PanelService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import PanelService from './PanelService';
|
||||
|
||||
export default PanelService;
|
||||
31
platform/core/src/services/ServiceProvidersManager.ts
Normal file
31
platform/core/src/services/ServiceProvidersManager.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import log from './../log.js';
|
||||
|
||||
/**
|
||||
* The ServiceProvidersManager allows for a React context provider class to be registered
|
||||
* for a particular service. This allows for extensions to register services
|
||||
* with context providers and the providers will be instantiated and added to the
|
||||
* DOM dynamically.
|
||||
*/
|
||||
export default class ServiceProvidersManager {
|
||||
public providers = {};
|
||||
|
||||
public constructor() {
|
||||
this.providers = {};
|
||||
}
|
||||
|
||||
registerProvider(serviceName, provider) {
|
||||
if (!serviceName) {
|
||||
log.warn(
|
||||
'Attempting to register a provider to a null/undefined service name. Exiting early.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
log.warn('Attempting to register a null/undefined provider. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.providers[serviceName] = provider;
|
||||
}
|
||||
}
|
||||
97
platform/core/src/services/ServicesManager.test.js
Normal file
97
platform/core/src/services/ServicesManager.test.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import ServicesManager from './ServicesManager';
|
||||
import log from '../log';
|
||||
|
||||
jest.mock('./../log');
|
||||
|
||||
describe('ServicesManager', () => {
|
||||
let servicesManager, commandsManager;
|
||||
|
||||
beforeEach(() => {
|
||||
commandsManager = {
|
||||
createContext: jest.fn(),
|
||||
getContext: jest.fn(),
|
||||
registerCommand: jest.fn(),
|
||||
};
|
||||
servicesManager = new ServicesManager(commandsManager);
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerServices()', () => {
|
||||
it('calls registerService() for each service', () => {
|
||||
servicesManager.registerService = jest.fn();
|
||||
|
||||
servicesManager.registerServices([
|
||||
{ name: 'UINotificationTestService', create: jest.fn() },
|
||||
{ name: 'UIModalTestService', create: jest.fn() },
|
||||
]);
|
||||
|
||||
expect(servicesManager.registerService.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls registerService() for each service passing its configuration if tuple', () => {
|
||||
servicesManager.registerService = jest.fn();
|
||||
const fakeConfiguration = { testing: true };
|
||||
|
||||
servicesManager.registerServices([
|
||||
{ name: 'UINotificationTestService', create: jest.fn() },
|
||||
[{ name: 'UIModalTestService', create: jest.fn() }, fakeConfiguration],
|
||||
]);
|
||||
|
||||
expect(servicesManager.registerService.mock.calls[1][1]).toEqual(fakeConfiguration);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerService()', () => {
|
||||
const fakeService = { name: 'UINotificationService', create: jest.fn() };
|
||||
|
||||
it('logs a warning if the service is null or undefined', () => {
|
||||
const undefinedService = undefined;
|
||||
const nullService = null;
|
||||
|
||||
servicesManager.registerService(undefinedService);
|
||||
servicesManager.registerService(nullService);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('logs a warning if the service does not have a name', () => {
|
||||
const serviceWithEmptyName = { name: '', create: jest.fn() };
|
||||
const serviceWithoutName = { create: jest.fn() };
|
||||
|
||||
servicesManager.registerService(serviceWithEmptyName);
|
||||
servicesManager.registerService(serviceWithoutName);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('logs a warning if the service does not have a create factory function', () => {
|
||||
const serviceWithoutCreate = { name: 'UINotificationService' };
|
||||
|
||||
servicesManager.registerService(serviceWithoutCreate);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('tracks which services have been registered', () => {
|
||||
servicesManager.registerService(fakeService);
|
||||
|
||||
expect(servicesManager.registeredServiceNames).toContain(fakeService.name);
|
||||
});
|
||||
|
||||
it('logs a warning if the service has an name that has already been registered', () => {
|
||||
servicesManager.registerService(fakeService);
|
||||
servicesManager.registerService(fakeService);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('pass dependencies and configuration to service create factory function', () => {
|
||||
const configuration = { config: 'Some configuration' };
|
||||
|
||||
servicesManager.registerService(fakeService, configuration);
|
||||
|
||||
expect(fakeService.create.mock.calls[0][0].configuration.config).toBe(configuration.config);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
platform/core/src/services/ServicesManager.ts
Normal file
85
platform/core/src/services/ServicesManager.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import log from './../log.js';
|
||||
import CommandsManager from '../classes/CommandsManager';
|
||||
import ExtensionManager from '../extensions/ExtensionManager';
|
||||
|
||||
export default class ServicesManager {
|
||||
public services: AppTypes.Services = {};
|
||||
public registeredServiceNames: string[] = [];
|
||||
private _commandsManager: CommandsManager;
|
||||
private _extensionManager: ExtensionManager;
|
||||
|
||||
constructor(commandsManager: CommandsManager) {
|
||||
this._commandsManager = commandsManager;
|
||||
this._extensionManager = null;
|
||||
this.services = {};
|
||||
this.registeredServiceNames = [];
|
||||
}
|
||||
|
||||
setExtensionManager(extensionManager) {
|
||||
this._extensionManager = extensionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new service.
|
||||
*
|
||||
* @param {Object} service
|
||||
* @param {Object} configuration
|
||||
*/
|
||||
registerService(service, configuration = {}) {
|
||||
if (!service) {
|
||||
log.warn('Attempting to register a null/undefined service. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!service.name) {
|
||||
log.warn(`Service name not set. Exiting early.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registeredServiceNames.includes(service.name)) {
|
||||
log.warn(
|
||||
`Service name ${service.name} has already been registered. Exiting before duplicating services.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (service.create) {
|
||||
this.services[service.name] = service.create({
|
||||
configuration,
|
||||
extensionManager: this._extensionManager,
|
||||
commandsManager: this._commandsManager,
|
||||
servicesManager: this,
|
||||
extensionManager: this._extensionManager,
|
||||
});
|
||||
if (service.altName) {
|
||||
// TODO - remove this registration
|
||||
this.services[service.altName] = this.services[service.name];
|
||||
}
|
||||
} else {
|
||||
log.warn(`Service create factory function not defined. Exiting early.`);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Track service registration */
|
||||
this.registeredServiceNames.push(service.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of services, or an array of arrays that contains service
|
||||
* configuration pairs.
|
||||
*
|
||||
* @param {Object[]} services - Array of services
|
||||
*/
|
||||
registerServices(services) {
|
||||
services.forEach(service => {
|
||||
const hasConfiguration = Array.isArray(service);
|
||||
|
||||
if (hasConfiguration) {
|
||||
const [ohifService, configuration] = service;
|
||||
this.registerService(ohifService, configuration);
|
||||
} else {
|
||||
this.registerService(service);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import { ExtensionManager } from '../../extensions';
|
||||
import ServicesManager from '../ServicesManager';
|
||||
import ViewportGridService from '../ViewportGridService';
|
||||
import { DisplaySet } from '../../types';
|
||||
|
||||
enum RequestType {
|
||||
/** Highest priority for loading*/
|
||||
Interaction = 'interaction',
|
||||
/** Second highest priority for loading*/
|
||||
Thumbnail = 'thumbnail',
|
||||
/** Third highest priority for loading, usually used for image loading in the background*/
|
||||
Prefetch = 'prefetch',
|
||||
/** Lower priority, often used for background computations in the worker */
|
||||
Compute = 'compute',
|
||||
}
|
||||
|
||||
export const EVENTS = {
|
||||
SERVICE_STARTED: 'event::studyPrefetcherService:started',
|
||||
SERVICE_STOPPED: 'event::studyPrefetcherService:stopped',
|
||||
DISPLAYSET_LOAD_PROGRESS: 'event::studyPrefetcherService:displaySetLoadProgress',
|
||||
DISPLAYSET_LOAD_COMPLETE: 'event::studyPrefetcherService:displaySetLoadComplete',
|
||||
};
|
||||
|
||||
/**
|
||||
* Order used for prefetching display set
|
||||
*/
|
||||
enum StudyPrefetchOrder {
|
||||
closest = 'closest',
|
||||
downward = 'downward',
|
||||
upward = 'upward',
|
||||
}
|
||||
|
||||
/**
|
||||
* Study Prefetcher configuration
|
||||
*/
|
||||
type StudyPrefetcherConfig = {
|
||||
/* Enable/disable study prefetching service */
|
||||
enabled: boolean;
|
||||
/* Number of displaysets to be prefetched */
|
||||
displaySetsCount: number;
|
||||
/**
|
||||
* Max number of concurrent prefetch requests
|
||||
* High numbers may impact on the time to load a new dropped series because
|
||||
* the browser will be busy with all prefetching requests. As soon as the
|
||||
* prefetch requests get fulfilled the new ones from the new dropped series
|
||||
* are sent to the server.
|
||||
*
|
||||
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
|
||||
* (need to add support for `AbortController` on Cornerstone)
|
||||
* */
|
||||
maxNumPrefetchRequests: number;
|
||||
/* Display sets prefetching order (closest, downward and upward) */
|
||||
order: StudyPrefetchOrder;
|
||||
};
|
||||
|
||||
type DisplaySetLoadingState = {
|
||||
displaySetInstanceUID: string;
|
||||
numInstances: number;
|
||||
pendingImageIds: Set<string>;
|
||||
loadedImageIds: Set<string>;
|
||||
failedImageIds: Set<string>;
|
||||
loadingProgress: number;
|
||||
};
|
||||
|
||||
type ImageRequest = {
|
||||
displaySetInstanceUID: string;
|
||||
imageId: string;
|
||||
aborted: boolean;
|
||||
};
|
||||
|
||||
type PubSubServiceSubscription = { unsubscribe: () => any };
|
||||
|
||||
interface ICache {
|
||||
isImageCached(imageId: string): boolean;
|
||||
}
|
||||
|
||||
interface IImageLoadPoolManager {
|
||||
addRequest(
|
||||
requestFn: () => Promise<any>,
|
||||
type: string,
|
||||
additionalDetails: Record<string, unknown>,
|
||||
priority?: number
|
||||
);
|
||||
clearRequestStack(type: string): void;
|
||||
}
|
||||
|
||||
interface IImageLoader {
|
||||
loadAndCacheImage(imageId: string, options: any): Promise<any>;
|
||||
}
|
||||
|
||||
type EventSubscription = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
interface IImageLoadEventsManager {
|
||||
addEventListeners(
|
||||
onImageLoaded: (evt: any) => void,
|
||||
onImageLoadFailed: (evt: any) => void
|
||||
): EventSubscription[];
|
||||
}
|
||||
|
||||
class StudyPrefetcherService extends PubSubService {
|
||||
private _extensionManager: ExtensionManager;
|
||||
private _servicesManager: ServicesManager;
|
||||
private _subscriptions: PubSubServiceSubscription[];
|
||||
private _activeDisplaySetsInstanceUIDs: string[] = [];
|
||||
private _pendingRequests: ImageRequest[] = [];
|
||||
private _inflightRequests = new Map<string, ImageRequest>();
|
||||
private _isRunning = false;
|
||||
private _displaySetLoadingStates = new Map<string, DisplaySetLoadingState>();
|
||||
private _imageIdsToDisplaySetsMap = new Map<string, Set<string>>();
|
||||
private config: StudyPrefetcherConfig = {
|
||||
/* Enable/disable study prefetching service */
|
||||
enabled: false,
|
||||
/* Number of displaysets to be prefetched */
|
||||
displaySetsCount: 1,
|
||||
/**
|
||||
* Max number of concurrent prefetch requests
|
||||
* High numbers may impact on the time to load a new dropped series because
|
||||
* the browser will be busy with all prefetching requests. As soon as the
|
||||
* prefetch requests get fulfilled the new ones from the new dropped series
|
||||
* are sent to the server.
|
||||
*
|
||||
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
|
||||
* (need to add support for `AbortController` on Cornerstone)
|
||||
* */
|
||||
maxNumPrefetchRequests: 10,
|
||||
/* Display sets prefetching order (closest, downward and upward) */
|
||||
order: StudyPrefetchOrder.downward,
|
||||
};
|
||||
|
||||
// Properties set by Cornerstone extension (initStudyPrefetcherService)
|
||||
public requestType: string = RequestType.Prefetch;
|
||||
public cache: ICache;
|
||||
public imageLoadPoolManager: IImageLoadPoolManager;
|
||||
public imageLoader: IImageLoader;
|
||||
public imageLoadEventsManager: IImageLoadEventsManager;
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'studyPrefetcherService',
|
||||
altName: 'StudyPrefetcherService',
|
||||
create: ({ configuration, servicesManager, extensionManager }): StudyPrefetcherService => {
|
||||
return new StudyPrefetcherService({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
configuration,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
constructor({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
configuration,
|
||||
}: {
|
||||
servicesManager: ServicesManager;
|
||||
extensionManager: ExtensionManager;
|
||||
configuration: StudyPrefetcherConfig;
|
||||
}) {
|
||||
super(EVENTS);
|
||||
|
||||
this._servicesManager = servicesManager;
|
||||
this._extensionManager = extensionManager;
|
||||
this._subscriptions = [];
|
||||
|
||||
Object.assign(this.config, configuration);
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
this._addEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* The onModeExit returns the service to the initial state.
|
||||
*/
|
||||
public onModeExit(): void {
|
||||
this._removeEventListeners();
|
||||
this._stopPrefetching();
|
||||
}
|
||||
|
||||
private _addImageLoadingEventsListeners() {
|
||||
const fnOnImageLoadCompleted = (imageId: string) => {
|
||||
// `sendNextRequests` must be called after image loaded/failed events
|
||||
// to make sure prefetch requests shall be sent as soon as the active
|
||||
// displaySets (active viewport) are loaded.
|
||||
//
|
||||
// PS: active display sets are not loaded by this service and that is why
|
||||
// the requests shall not be in the inflight queue.
|
||||
if (!this._inflightRequests.get(imageId)) {
|
||||
this._sendNextRequests();
|
||||
}
|
||||
};
|
||||
|
||||
const fnImageLoadedEventListener = evt => {
|
||||
const { image } = evt.detail;
|
||||
const { imageId } = image;
|
||||
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
fnOnImageLoadCompleted(imageId);
|
||||
};
|
||||
|
||||
const fnImageLoadFailedEventListener = evt => {
|
||||
const { imageId } = evt.detail;
|
||||
|
||||
this._moveImageIdToFailedSet(imageId);
|
||||
fnOnImageLoadCompleted(imageId);
|
||||
};
|
||||
|
||||
return this.imageLoadEventsManager.addEventListeners(
|
||||
fnImageLoadedEventListener,
|
||||
fnImageLoadFailedEventListener
|
||||
);
|
||||
}
|
||||
|
||||
private _addServicesListeners() {
|
||||
const { displaySetService, viewportGridService } = this._servicesManager.services;
|
||||
|
||||
// Restart the prefetcher after any change to the displaySets
|
||||
// (eg: sorting the displaySets on StudyBrowser)
|
||||
const displaySetsChangedSubscription = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
|
||||
() => this._syncWithActiveViewport({ forceRestart: true })
|
||||
);
|
||||
|
||||
// Loads new datasets when making a new viewport active
|
||||
const viewportGridActiveViewportIdSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
|
||||
({ viewportId }) => this._syncWithActiveViewport({ activeViewportId: viewportId })
|
||||
);
|
||||
|
||||
// Continue loading datasets after changing the layout (eg: from 1x1 to 2x1)
|
||||
const viewportGridLayoutChangedSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.LAYOUT_CHANGED,
|
||||
() => this._syncWithActiveViewport()
|
||||
);
|
||||
|
||||
// Loads new datasets after loading a new display set on a viewport
|
||||
const viewportGridStateChangedSubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.GRID_STATE_CHANGED,
|
||||
() => this._syncWithActiveViewport()
|
||||
);
|
||||
|
||||
// Loads the first datasets right after opening the viewer
|
||||
const viewportGridViewportreadySubscription = viewportGridService.subscribe(
|
||||
ViewportGridService.EVENTS.VIEWPORTS_READY,
|
||||
() => {
|
||||
this._syncWithActiveViewport();
|
||||
this._startPrefetching();
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
displaySetsChangedSubscription,
|
||||
viewportGridActiveViewportIdSubscription,
|
||||
viewportGridLayoutChangedSubscription,
|
||||
viewportGridStateChangedSubscription,
|
||||
viewportGridViewportreadySubscription,
|
||||
];
|
||||
}
|
||||
|
||||
private _addEventListeners() {
|
||||
const imageLoadingEventsSubscriptions = this._addImageLoadingEventsListeners();
|
||||
const servicesSubscriptions = this._addServicesListeners();
|
||||
|
||||
this._subscriptions.push(...imageLoadingEventsSubscriptions);
|
||||
this._subscriptions.push(...servicesSubscriptions);
|
||||
}
|
||||
|
||||
private _removeEventListeners() {
|
||||
this._subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
this._subscriptions = [];
|
||||
}
|
||||
|
||||
private _syncWithActiveViewport({
|
||||
activeViewportId,
|
||||
forceRestart,
|
||||
}: {
|
||||
activeViewportId?: string;
|
||||
forceRestart?: boolean;
|
||||
} = {}) {
|
||||
const { viewportGridService } = this._servicesManager.services;
|
||||
const viewportGridServiceState = viewportGridService.getState();
|
||||
const { viewports } = viewportGridServiceState;
|
||||
|
||||
activeViewportId = activeViewportId ?? viewportGridServiceState.activeViewportId;
|
||||
|
||||
// If may be null when the viewer is loaded
|
||||
if (!activeViewportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeViewport = viewports.get(activeViewportId);
|
||||
const displaySetUpdated = this._setActiveDisplaySetsUIDs(activeViewport.displaySetInstanceUIDs);
|
||||
|
||||
if (forceRestart || displaySetUpdated) {
|
||||
this._restartPrefetching();
|
||||
}
|
||||
}
|
||||
|
||||
private _setActiveDisplaySetsUIDs(newActiveDisplaySetInstanceUIDs: string[]): boolean {
|
||||
const sameDisplaySets =
|
||||
newActiveDisplaySetInstanceUIDs.length === this._activeDisplaySetsInstanceUIDs.length &&
|
||||
newActiveDisplaySetInstanceUIDs.every(uid =>
|
||||
this._activeDisplaySetsInstanceUIDs.includes(uid)
|
||||
);
|
||||
|
||||
if (sameDisplaySets) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._activeDisplaySetsInstanceUIDs = [...newActiveDisplaySetInstanceUIDs];
|
||||
this._restartPrefetching();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _areActiveDisplaySetsLoaded() {
|
||||
const { _activeDisplaySetsInstanceUIDs: displaySetsInstanceUIDs } = this;
|
||||
|
||||
return (
|
||||
displaySetsInstanceUIDs.length &&
|
||||
displaySetsInstanceUIDs.every(
|
||||
displaySetsInstanceUID =>
|
||||
this._displaySetLoadingStates.get(displaySetsInstanceUID).loadingProgress >= 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _getClosestDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
let previousIndex = activeDisplaySetIndex - 1;
|
||||
let nextIndex = activeDisplaySetIndex + 1;
|
||||
|
||||
while (previousIndex >= 0 || nextIndex < displaySets.length) {
|
||||
if (previousIndex >= 0) {
|
||||
sortedDisplaySets.push(displaySets[previousIndex]);
|
||||
previousIndex--;
|
||||
}
|
||||
|
||||
if (nextIndex < displaySets.length) {
|
||||
sortedDisplaySets.push(displaySets[nextIndex]);
|
||||
nextIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getDownwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
|
||||
for (let i = activeDisplaySetIndex + 1; i < displaySets.length; i++) {
|
||||
sortedDisplaySets.push(displaySets[i]);
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getUpwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
|
||||
const sortedDisplaySets = [];
|
||||
|
||||
for (let i = activeDisplaySetIndex - 1; i >= 0 && i !== activeDisplaySetIndex; i--) {
|
||||
sortedDisplaySets.push(displaySets[i]);
|
||||
}
|
||||
|
||||
return sortedDisplaySets;
|
||||
}
|
||||
|
||||
private _getSortedDisplaySetsToPrefetch(displaySets: DisplaySet[]): DisplaySet[] {
|
||||
if (!this._activeDisplaySetsInstanceUIDs?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { displaySetsCount } = this.config;
|
||||
const activeDisplaySetsInstanceUIDs = this._activeDisplaySetsInstanceUIDs;
|
||||
const [activeDisplaySetUID] = activeDisplaySetsInstanceUIDs;
|
||||
const activeDisplaySetIndex = displaySets.findIndex(
|
||||
ds => ds.displaySetInstanceUID === activeDisplaySetUID
|
||||
);
|
||||
const getDisplaySetsFunctionsMap = {
|
||||
[StudyPrefetchOrder.closest]: this._getClosestDisplaySets,
|
||||
[StudyPrefetchOrder.downward]: this._getDownwardDisplaySets,
|
||||
[StudyPrefetchOrder.upward]: this._getUpwardDisplaySets,
|
||||
};
|
||||
const { order } = this.config;
|
||||
const fnGetDisplaySets = getDisplaySetsFunctionsMap[order];
|
||||
|
||||
if (!fnGetDisplaySets) {
|
||||
throw new Error(`Invalid order (${order})`);
|
||||
}
|
||||
|
||||
// Creates a `Set` to look for UIDs in O(1) instead of O(n)
|
||||
const uidsSet = new Set(activeDisplaySetsInstanceUIDs);
|
||||
|
||||
// Remove any active displaySet that may still be in the activeDisplaySetsInstanceUIDs.
|
||||
// That may happen when activeDisplaySetsInstanceUIDs has more than one element.
|
||||
return fnGetDisplaySets
|
||||
.call(this, displaySets, activeDisplaySetIndex)
|
||||
.filter(ds => !uidsSet.has(ds.displaySetInstanceUID))
|
||||
.slice(0, displaySetsCount);
|
||||
}
|
||||
|
||||
private _getDisplaySets() {
|
||||
const { displaySetService } = this._servicesManager.services;
|
||||
const displaySets = [...displaySetService.getActiveDisplaySets()];
|
||||
const displaySetsToPrefetch = this._getSortedDisplaySetsToPrefetch(displaySets);
|
||||
|
||||
return { displaySets, displaySetsToPrefetch };
|
||||
}
|
||||
|
||||
private _updateImageIdsDisplaySetMap(displaySetInstanceUID: string, imageIds: string[]): void {
|
||||
for (const imageId of imageIds) {
|
||||
let displaySetsInstanceUIDsMap = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDsMap) {
|
||||
displaySetsInstanceUIDsMap = new Set();
|
||||
this._imageIdsToDisplaySetsMap.set(imageId, displaySetsInstanceUIDsMap);
|
||||
}
|
||||
|
||||
displaySetsInstanceUIDsMap.add(displaySetInstanceUID);
|
||||
}
|
||||
}
|
||||
|
||||
private _getImageIdsForDisplaySet(displaySet: DisplaySet): string[] {
|
||||
const dataSource = this._extensionManager.getActiveDataSource()[0];
|
||||
|
||||
return dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
}
|
||||
|
||||
private _updateDisplaySetLoadingProgress(displaySetLoadingState: DisplaySetLoadingState) {
|
||||
const { numInstances, loadedImageIds, failedImageIds } = displaySetLoadingState;
|
||||
const loadingProgress = (loadedImageIds.size + failedImageIds.size) / numInstances;
|
||||
|
||||
displaySetLoadingState.loadingProgress = loadingProgress;
|
||||
}
|
||||
|
||||
private _addDisplaySetLoadingState(displaySet: DisplaySet): void {
|
||||
const { displaySetInstanceUID } = displaySet;
|
||||
const imageIds = this._getImageIdsForDisplaySet(displaySet);
|
||||
let displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
|
||||
if (displaySetLoadingState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingImageIds = new Set<string>(imageIds);
|
||||
const loadedImageIds = new Set<string>();
|
||||
|
||||
// Needs to check which image is already loaded to update the progress properly
|
||||
// because some images may already be loaded (thumbnails and viewports).
|
||||
for (const imageId of imageIds) {
|
||||
if (this.cache.isImageCached(imageId)) {
|
||||
loadedImageIds.add(imageId);
|
||||
} else {
|
||||
pendingImageIds.add(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
displaySetLoadingState = {
|
||||
displaySetInstanceUID,
|
||||
numInstances: imageIds.length,
|
||||
pendingImageIds,
|
||||
loadedImageIds,
|
||||
failedImageIds: new Set(),
|
||||
loadingProgress: 0,
|
||||
};
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._displaySetLoadingStates.set(displaySetInstanceUID, displaySetLoadingState);
|
||||
this._updateImageIdsDisplaySetMap(displaySetInstanceUID, imageIds);
|
||||
|
||||
// Notify the UI that something is already loaded (eg: update StudyBrowser)
|
||||
if (loadedImageIds.size) {
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
}
|
||||
|
||||
private _loadDisplaySets() {
|
||||
const { displaySets, displaySetsToPrefetch } = this._getDisplaySets();
|
||||
|
||||
displaySets.forEach(displaySet => this._addDisplaySetLoadingState(displaySet));
|
||||
displaySetsToPrefetch.forEach(displaySet => this._enqueueDisplaySetImagesRequests(displaySet));
|
||||
}
|
||||
|
||||
private _moveImageIdToLoadedSet(imageId: string): boolean {
|
||||
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { pendingImageIds, loadedImageIds } = displaySetLoadingState;
|
||||
|
||||
pendingImageIds.delete(imageId);
|
||||
loadedImageIds.add(imageId);
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _moveImageIdToFailedSet(imageId: string): boolean {
|
||||
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
|
||||
|
||||
if (!displaySetsInstanceUIDs) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { pendingImageIds, failedImageIds } = displaySetLoadingState;
|
||||
|
||||
pendingImageIds.delete(imageId);
|
||||
failedImageIds.add(imageId);
|
||||
|
||||
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
|
||||
this._triggerDisplaySetEvents(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _triggerDisplaySetEvents(displaySetInstanceUID: string) {
|
||||
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
|
||||
const { loadingProgress, numInstances } = displaySetLoadingState;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_PROGRESS, {
|
||||
displaySetInstanceUID,
|
||||
numInstances,
|
||||
loadingProgress,
|
||||
});
|
||||
|
||||
if (loadingProgress >= 1) {
|
||||
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_COMPLETE, {
|
||||
displaySetInstanceUID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onImagePrefetchSuccess(imageRequest: ImageRequest) {
|
||||
if (imageRequest.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { imageId } = imageRequest;
|
||||
|
||||
this._inflightRequests.delete(imageId);
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
|
||||
// `sendNextRequests` must be called after removing the request from the inflight
|
||||
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
|
||||
this._sendNextRequests();
|
||||
}
|
||||
|
||||
private _onImagePrefetchFailed(imageRequest, error) {
|
||||
if (imageRequest.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`An error ocurred when trying to load "${imageRequest.imageId}"`, error);
|
||||
|
||||
const { imageId } = imageRequest;
|
||||
|
||||
this._inflightRequests.delete(imageId);
|
||||
this._moveImageIdToFailedSet(imageId);
|
||||
|
||||
// `sendNextRequests` must be called after removing the request from the inflight
|
||||
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
|
||||
this._sendNextRequests();
|
||||
}
|
||||
|
||||
private async _sendNextRequests() {
|
||||
// If the service has stopped with async requests in progress this method may
|
||||
// get called again when each of those requests are fulfilled.
|
||||
if (!this._isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Does not send any prefetch request until the active display sets are loaded
|
||||
if (!this._areActiveDisplaySetsLoaded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _pendingRequests: pendingRequests, _inflightRequests: inflightRequests } = this;
|
||||
const { maxNumPrefetchRequests } = this.config;
|
||||
|
||||
if (!pendingRequests.length || inflightRequests.size >= maxNumPrefetchRequests) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numImageRequests = Math.min(
|
||||
pendingRequests.length,
|
||||
maxNumPrefetchRequests - inflightRequests.size
|
||||
);
|
||||
const imageRequests = this._pendingRequests.splice(0, numImageRequests);
|
||||
|
||||
imageRequests.forEach(imageRequest => {
|
||||
const { imageId } = imageRequest;
|
||||
const options = {
|
||||
priority: -5,
|
||||
requestType: this.requestType,
|
||||
additionalDetails: { imageId },
|
||||
preScale: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
this.imageLoadPoolManager.addRequest(
|
||||
async () =>
|
||||
this.imageLoader.loadAndCacheImage(imageId, options).then(
|
||||
_image => this._onImagePrefetchSuccess(imageRequest),
|
||||
error => this._onImagePrefetchFailed(imageRequest, error)
|
||||
),
|
||||
this.requestType,
|
||||
{ imageId }
|
||||
);
|
||||
|
||||
inflightRequests.set(imageId, imageRequest);
|
||||
});
|
||||
}
|
||||
|
||||
private _enqueueDisplaySetImagesRequests(displaySet: DisplaySet) {
|
||||
const { displaySetInstanceUID } = displaySet;
|
||||
const imageIds = this._getImageIdsForDisplaySet(displaySet);
|
||||
|
||||
imageIds.forEach(imageId => {
|
||||
if (this.cache.isImageCached(imageId)) {
|
||||
this._moveImageIdToLoadedSet(imageId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingRequests.push({
|
||||
displaySetInstanceUID,
|
||||
imageId,
|
||||
aborted: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start prefetching the display sets based on the active viewport and app configuration.
|
||||
*/
|
||||
private _startPrefetching(): void {
|
||||
if (this._isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.enabled) {
|
||||
console.log('StudyPrefetcher is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
this._isRunning = true;
|
||||
|
||||
this._loadDisplaySets();
|
||||
this._sendNextRequests();
|
||||
this._broadcastEvent(this.EVENTS.SERVICE_STARTED, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop prefetching the display sets.
|
||||
* All internal variables are cleared but activeDisplaySetsInstanceUIDs otherwise restart would not work.
|
||||
*/
|
||||
private _stopPrefetching(): void {
|
||||
if (!this._isRunning) {
|
||||
return;
|
||||
}
|
||||
this._isRunning = false;
|
||||
|
||||
// Mark all inflight requests as aborted before clearing the map.
|
||||
this._inflightRequests.forEach(inflightRequest => (inflightRequest.aborted = true));
|
||||
|
||||
this._pendingRequests = [];
|
||||
this._displaySetLoadingStates.clear();
|
||||
this._imageIdsToDisplaySetsMap.clear();
|
||||
this._inflightRequests.clear();
|
||||
this.imageLoadPoolManager.clearRequestStack(IMAGE_REQUEST_TYPE);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.SERVICE_STOPPED, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart prefetching in case it is already running.
|
||||
*/
|
||||
private _restartPrefetching(): void {
|
||||
if (this._isRunning) {
|
||||
this._stopPrefetching();
|
||||
this._startPrefetching();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { StudyPrefetcherService as default, StudyPrefetcherService };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { StudyPrefetcherService } from './StudyPrefetcherService';
|
||||
|
||||
export { StudyPrefetcherService as default, StudyPrefetcherService };
|
||||
567
platform/core/src/services/ToolBarService/ToolbarService.ts
Normal file
567
platform/core/src/services/ToolBarService/ToolbarService.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { CommandsManager } from '../../classes';
|
||||
import { ExtensionManager } from '../../extensions';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import type { RunCommand } from '../../types/Command';
|
||||
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types';
|
||||
|
||||
const EVENTS = {
|
||||
TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified',
|
||||
TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified',
|
||||
};
|
||||
|
||||
export default class ToolbarService extends PubSubService {
|
||||
public static REGISTRATION = {
|
||||
name: 'toolbarService',
|
||||
altName: 'ToolBarService',
|
||||
create: ({ commandsManager, extensionManager, servicesManager }) => {
|
||||
return new ToolbarService(commandsManager, extensionManager, servicesManager);
|
||||
},
|
||||
};
|
||||
|
||||
public static createButton(options: {
|
||||
id: string;
|
||||
label: string;
|
||||
commands: RunCommand;
|
||||
icon?: string;
|
||||
tooltip?: string;
|
||||
evaluate?: EvaluatePublic;
|
||||
listeners?: Record<string, RunCommand>;
|
||||
}): ButtonProps {
|
||||
const { id, icon, label, commands, tooltip, evaluate, listeners } = options;
|
||||
return {
|
||||
id,
|
||||
icon,
|
||||
label,
|
||||
commands,
|
||||
tooltip: tooltip || label,
|
||||
evaluate,
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
state: {
|
||||
// all buttons in the toolbar with their props
|
||||
buttons: Record<string, Button>;
|
||||
// the buttons in the toolbar, grouped by section, with their ids
|
||||
buttonSections: Record<string, string[]>;
|
||||
} = {
|
||||
buttons: {},
|
||||
buttonSections: {},
|
||||
};
|
||||
|
||||
_commandsManager: CommandsManager;
|
||||
_extensionManager: ExtensionManager;
|
||||
_servicesManager: AppTypes.ServicesManager;
|
||||
_evaluateFunction: Record<string, EvaluateFunction> = {};
|
||||
_serviceSubscriptions = [];
|
||||
|
||||
constructor(
|
||||
commandsManager: CommandsManager,
|
||||
extensionManager: ExtensionManager,
|
||||
servicesManager: AppTypes.ServicesManager
|
||||
) {
|
||||
super(EVENTS);
|
||||
this._commandsManager = commandsManager;
|
||||
this._extensionManager = extensionManager;
|
||||
this._servicesManager = servicesManager;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
// this.unsubscriptions.forEach(unsub => unsub());
|
||||
this.state = {
|
||||
buttons: {},
|
||||
buttonSections: {},
|
||||
};
|
||||
this.unsubscriptions = [];
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an evaluate function with the specified name.
|
||||
*
|
||||
* @param name - The name of the evaluate function.
|
||||
* @param handler - The evaluate function handler.
|
||||
*/
|
||||
public registerEvaluateFunction(name: string, handler: EvaluateFunction) {
|
||||
this._evaluateFunction[name] = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a service and its event to listen for updates and refreshes the toolbar state when the event is triggered.
|
||||
* @param service - The service to register.
|
||||
* @param event - The event to listen for.
|
||||
*/
|
||||
public registerEventForToolbarUpdate(service, events) {
|
||||
const { viewportGridService } = this._servicesManager.services;
|
||||
const callback = () => {
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
this.refreshToolbarState({ viewportId });
|
||||
};
|
||||
|
||||
const unsubscriptions = events.map(event => {
|
||||
if (service.subscribe) {
|
||||
return service.subscribe(event, callback);
|
||||
} else if (service.addEventListener) {
|
||||
return service.addEventListener(event, callback);
|
||||
}
|
||||
});
|
||||
|
||||
unsubscriptions.forEach(unsub => this._serviceSubscriptions.push(unsub));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes buttons from the toolbar.
|
||||
* @param buttonId - The button to be removed.
|
||||
*/
|
||||
public removeButton(buttonId: string) {
|
||||
if (this.state.buttons[buttonId]) {
|
||||
delete this.state.buttons[buttonId];
|
||||
}
|
||||
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
|
||||
...this.state,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds buttons to the toolbar.
|
||||
* @param buttons - The buttons to be added.
|
||||
*/
|
||||
public addButtons(buttons: Button[]): void {
|
||||
buttons.forEach(button => {
|
||||
if (!this.state.buttons[button.id]) {
|
||||
if (!button.props) {
|
||||
button.props = {};
|
||||
}
|
||||
|
||||
this.state.buttons[button.id] = button;
|
||||
}
|
||||
});
|
||||
|
||||
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
|
||||
...this.state,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} interaction - can be undefined to run nothing
|
||||
* @param {*} options is an optional set of extra commandOptions
|
||||
* used for calling the specified interaction. That is, the command is
|
||||
* called with {...commandOptions,...options}
|
||||
*/
|
||||
public recordInteraction(
|
||||
interaction,
|
||||
options?: {
|
||||
refreshProps: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
) {
|
||||
// if interaction is a string, we can assume it is the itemId
|
||||
// and get the props to get the other properties
|
||||
if (typeof interaction === 'string') {
|
||||
interaction = this.getButtonProps(interaction);
|
||||
}
|
||||
|
||||
const itemId = interaction.itemId ?? interaction.id;
|
||||
interaction.itemId = itemId;
|
||||
|
||||
let commands = Array.isArray(interaction.commands)
|
||||
? interaction.commands
|
||||
: [interaction.commands];
|
||||
|
||||
if (!commands?.length) {
|
||||
this.refreshToolbarState({
|
||||
...options?.refreshProps,
|
||||
itemId,
|
||||
interaction,
|
||||
});
|
||||
}
|
||||
|
||||
const commandOptions = { ...options, ...interaction };
|
||||
|
||||
commands = commands.map(command => {
|
||||
if (typeof command === 'function') {
|
||||
return () => {
|
||||
command({
|
||||
...commandOptions,
|
||||
commandsManager: this._commandsManager,
|
||||
servicesManager: this._servicesManager,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return command;
|
||||
});
|
||||
|
||||
// if still no commands, return
|
||||
commands = commands.filter(Boolean);
|
||||
|
||||
if (!commands.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Loop through commands and run them with the combined options
|
||||
this._commandsManager.run(commands, commandOptions);
|
||||
|
||||
this.refreshToolbarState({
|
||||
...options?.refreshProps,
|
||||
itemId,
|
||||
interaction,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates the state of the toolbar after an interaction, it accepts
|
||||
* props that get passed to the buttons
|
||||
*
|
||||
* @param refreshProps - The props that buttons need to get evaluated, they can be
|
||||
* { viewportId, toolGroup} for cornerstoneTools.
|
||||
*
|
||||
* Todo: right now refreshToolbarState should be used in the context where
|
||||
* we have access to the toolGroup and viewportId, but we should be able to
|
||||
* pass the props to the toolbar service and it should be able to decide
|
||||
* which buttons to evaluate based on the props
|
||||
*/
|
||||
public refreshToolbarState(refreshProps) {
|
||||
const buttons = this.state.buttons;
|
||||
|
||||
// Tracks evaluated buttons to avoid re-evaluating them (this will
|
||||
// cause issue for toggles where if the button is in primary
|
||||
// and secondary it will be evaluated twice)
|
||||
const evaluationResults = new Map();
|
||||
|
||||
const evaluateButtonProps = (button, props, refreshProps) => {
|
||||
if (evaluationResults.has(button.id)) {
|
||||
const { disabled, className, isActive } = evaluationResults.get(button.id);
|
||||
return { ...props, disabled, className, isActive };
|
||||
} else {
|
||||
const evaluated = props.evaluate?.({ ...refreshProps, button });
|
||||
const updatedProps = {
|
||||
...props,
|
||||
...evaluated,
|
||||
disabled: evaluated?.disabled || false,
|
||||
className: evaluated?.className || '',
|
||||
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
|
||||
};
|
||||
evaluationResults.set(button.id, updatedProps);
|
||||
return updatedProps;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => {
|
||||
const isNested = (button.props as NestedButtonProps)?.groupId;
|
||||
|
||||
if (!isNested) {
|
||||
this.handleEvaluate(button.props);
|
||||
const buttonProps = button.props as ButtonProps;
|
||||
|
||||
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
|
||||
acc[button.id] = {
|
||||
...button,
|
||||
props: updatedProps,
|
||||
};
|
||||
} else {
|
||||
let buttonProps = button.props as NestedButtonProps;
|
||||
// if it is nested we should perform evaluate on each item in the group
|
||||
this.handleEvaluateNested(buttonProps);
|
||||
|
||||
const { evaluate: groupEvaluate } = buttonProps;
|
||||
|
||||
const groupEvaluated = groupEvaluate?.({ ...refreshProps, button });
|
||||
// handle group evaluate function which might switch the primary
|
||||
// item in the group
|
||||
buttonProps = {
|
||||
...buttonProps,
|
||||
primary: groupEvaluated?.primary || buttonProps.primary,
|
||||
};
|
||||
|
||||
const { primary, items } = buttonProps;
|
||||
|
||||
// primary and items evaluate functions
|
||||
let updatedPrimary;
|
||||
if (primary) {
|
||||
updatedPrimary = evaluateButtonProps(primary, primary, refreshProps);
|
||||
}
|
||||
const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps));
|
||||
buttonProps = {
|
||||
...buttonProps,
|
||||
primary: updatedPrimary,
|
||||
items: updatedItems,
|
||||
};
|
||||
|
||||
acc[button.id] = {
|
||||
...button,
|
||||
props: buttonProps,
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
this.setButtons(refreshedButtons);
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the buttons for the toolbar, don't use this method to record an
|
||||
* interaction, since it doesn't update the state of the buttons, use
|
||||
* this if you know the buttons you want to set and you want to set them
|
||||
* all at once.
|
||||
* @param buttons - The buttons to set.
|
||||
*/
|
||||
public setButtons(buttons) {
|
||||
this.state.buttons = buttons;
|
||||
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
|
||||
buttons: this.state.buttons,
|
||||
buttonSections: this.state.buttonSections,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a button by its ID.
|
||||
* @param id - The ID of the button to retrieve.
|
||||
* @returns The button with the specified ID.
|
||||
*/
|
||||
public getButton(id: string): Button {
|
||||
return this.state.buttons[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the buttons from the toolbar service.
|
||||
* @returns An array of buttons.
|
||||
*/
|
||||
public getButtons() {
|
||||
return this.state.buttons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the button properties for the specified button ID.
|
||||
* It prioritizes nested buttons over regular buttons if the ID is found
|
||||
* in both.
|
||||
*
|
||||
* @param id - The ID of the button.
|
||||
* @returns The button properties.
|
||||
*/
|
||||
public getButtonProps(id: string): ButtonProps {
|
||||
for (const buttonId of Object.keys(this.state.buttons)) {
|
||||
const { primary, items } = (this.state.buttons[buttonId].props as NestedButtonProps) || {};
|
||||
if (primary?.id === id) {
|
||||
return primary;
|
||||
}
|
||||
const found = items?.find(childButton => childButton.id === id);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
// This should be checked after we checked the nested buttons, since
|
||||
// we are checking based on the ids, the nested objects are higher priority
|
||||
// and more specific
|
||||
if (this.state.buttons[id]) {
|
||||
return this.state.buttons[id].props as ButtonProps;
|
||||
}
|
||||
}
|
||||
|
||||
_getButtonUITypes() {
|
||||
const registeredToolbarModules = this._extensionManager.modules['toolbarModule'];
|
||||
|
||||
if (!Array.isArray(registeredToolbarModules)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return registeredToolbarModules.reduce((buttonTypes, toolbarModule) => {
|
||||
toolbarModule.module.forEach(def => {
|
||||
buttonTypes[def.name] = def;
|
||||
});
|
||||
|
||||
return buttonTypes;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a button section with the specified key and buttons.
|
||||
* @param {string} key - The key of the button section.
|
||||
* @param {Array} buttons - The buttons to be added to the section.
|
||||
*/
|
||||
createButtonSection(key, buttons) {
|
||||
if (this.state.buttonSections[key]) {
|
||||
this.state.buttonSections[key].push(...buttons);
|
||||
} else {
|
||||
this.state.buttonSections[key] = buttons;
|
||||
}
|
||||
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the button section with the specified sectionId.
|
||||
*
|
||||
* @param sectionId - The ID of the button section to retrieve.
|
||||
* @param props - Optional additional properties for mapping the button to display.
|
||||
* @returns An array of buttons in the specified section, mapped to their display representation.
|
||||
*/
|
||||
getButtonSection(sectionId: string, props?: Record<string, unknown>) {
|
||||
const buttonSectionIds = this.state.buttonSections[sectionId];
|
||||
|
||||
return (
|
||||
buttonSectionIds?.map(btnId => {
|
||||
const btn = this.state.buttons[btnId];
|
||||
return this._mapButtonToDisplay(btn, props);
|
||||
}) || []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the tool name for a given button.
|
||||
* @param button - The button object.
|
||||
* @returns The tool name associated with the button.
|
||||
*/
|
||||
getToolNameForButton(button) {
|
||||
const { props } = button;
|
||||
|
||||
const commands = props?.commands || button.commands;
|
||||
const commandsArray = Array.isArray(commands) ? commands : [commands];
|
||||
const firstCommand = commandsArray[0];
|
||||
|
||||
if (firstCommand?.commandOptions) {
|
||||
return firstCommand.commandOptions.toolName ?? props?.id ?? button.id;
|
||||
}
|
||||
|
||||
// use id as a fallback for toolName
|
||||
return props?.id ?? button.id;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} btn
|
||||
* @param {*} btnSection
|
||||
* @param {*} metadata
|
||||
* @param {*} props - Props set by the Viewer layer
|
||||
*/
|
||||
_mapButtonToDisplay(btn, props) {
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, uiType, component } = btn;
|
||||
const { groupId } = btn.props;
|
||||
|
||||
const buttonTypes = this._getButtonUITypes();
|
||||
|
||||
const buttonType = buttonTypes[uiType];
|
||||
|
||||
if (!buttonType) {
|
||||
return;
|
||||
}
|
||||
|
||||
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
|
||||
|
||||
return {
|
||||
id,
|
||||
Component: component || buttonType.defaultComponent,
|
||||
componentProps: Object.assign({}, btn.props, props),
|
||||
};
|
||||
}
|
||||
|
||||
handleEvaluateNested = props => {
|
||||
const { primary, items } = props;
|
||||
// handle group evaluate function
|
||||
this.handleEvaluate(props);
|
||||
|
||||
// primary and items evaluate functions
|
||||
if (primary) {
|
||||
this.handleEvaluate(primary);
|
||||
}
|
||||
items.forEach(item => this.handleEvaluate(item));
|
||||
};
|
||||
|
||||
handleEvaluate = props => {
|
||||
const { evaluate, options } = props;
|
||||
|
||||
if (typeof options === 'string') {
|
||||
// get the custom option component from the extension manager and set it as the optionComponent
|
||||
const buttonTypes = this._getButtonUITypes();
|
||||
const optionComponent = buttonTypes[options]?.defaultComponent;
|
||||
props.options = {
|
||||
optionComponent,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof evaluate === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(evaluate)) {
|
||||
const evaluators = evaluate.map(evaluator => {
|
||||
const isObject = typeof evaluator === 'object';
|
||||
|
||||
const evaluatorName = isObject ? evaluator.name : evaluator;
|
||||
|
||||
const evaluateFunction = this._evaluateFunction[evaluatorName];
|
||||
|
||||
if (!evaluateFunction) {
|
||||
throw new Error(
|
||||
`Evaluate function not found for name: ${evaluatorName}, you can register an evaluate function with the getToolbarModule in your extensions`
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject) {
|
||||
return args => evaluateFunction({ ...args, ...evaluator });
|
||||
}
|
||||
|
||||
return evaluateFunction;
|
||||
});
|
||||
|
||||
props.evaluate = args => {
|
||||
const results = evaluators.map(evaluator => evaluator(args));
|
||||
const mergedResult = results.reduce((acc, result) => {
|
||||
return {
|
||||
...acc,
|
||||
...result,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return mergedResult;
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof evaluate === 'string') {
|
||||
const evaluateFunction = this._evaluateFunction[evaluate];
|
||||
|
||||
if (evaluateFunction) {
|
||||
props.evaluate = evaluateFunction;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Evaluate function not found for name: ${evaluate}, you can register an evaluate function with the getToolbarModule in your extensions`
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof evaluate === 'object') {
|
||||
const { name, ...options } = evaluate;
|
||||
const evaluateFunction = this._evaluateFunction[name];
|
||||
if (evaluateFunction) {
|
||||
props.evaluate = args => evaluateFunction({ ...args, ...options });
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Evaluate function not found for name: ${name}, you can register an evaluate function with the getToolbarModule in your extensions`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
getButtonComponentForUIType(uiType: string) {
|
||||
return uiType ? (this._getButtonUITypes()[uiType]?.defaultComponent ?? null) : null;
|
||||
}
|
||||
|
||||
clearButtonSection(buttonSection: string) {
|
||||
this.state.buttonSections[buttonSection] = [];
|
||||
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/ToolBarService/index.ts
Normal file
3
platform/core/src/services/ToolBarService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ToolbarService from './ToolbarService';
|
||||
|
||||
export default ToolbarService;
|
||||
53
platform/core/src/services/ToolBarService/types.ts
Normal file
53
platform/core/src/services/ToolBarService/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { RunCommand } from '../../types/Command';
|
||||
|
||||
export type EvaluatePublic =
|
||||
| string
|
||||
| EvaluateFunction
|
||||
| EvaluateObject
|
||||
| (string | EvaluateFunction | EvaluateObject)[];
|
||||
|
||||
export type EvaluateFunction = (props: Record<string, unknown>) => {
|
||||
disabled: boolean;
|
||||
className: string;
|
||||
};
|
||||
|
||||
export type EvaluateObject = {
|
||||
name: string;
|
||||
// Allow any additional properties
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ButtonProps = {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
commands?: RunCommand;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
evaluate?: EvaluatePublic;
|
||||
listeners?: Record<string, RunCommand>;
|
||||
};
|
||||
|
||||
export type NestedButtonProps = {
|
||||
groupId: string;
|
||||
// group evaluate which is different
|
||||
// from the evaluate function for the primary and items
|
||||
evaluate?: EvaluatePublic;
|
||||
items: ButtonProps[];
|
||||
primary: ButtonProps & {
|
||||
// Todo: this is really ugly but really we don't have any other option
|
||||
// the ui design requires this since the button should be rounded if
|
||||
// active otherwise it should not be rounded
|
||||
isActive?: boolean;
|
||||
};
|
||||
secondary: ButtonProps;
|
||||
};
|
||||
|
||||
export type Button = {
|
||||
id: string;
|
||||
props: ButtonProps | NestedButtonProps;
|
||||
// button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup')
|
||||
// extensions can provide custom components for these types
|
||||
uiType: string;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
|
||||
class UIDialogService extends PubSubService {
|
||||
public static readonly EVENTS = {};
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'uiDialogService',
|
||||
altName: 'UIDialogService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new UIDialogService();
|
||||
},
|
||||
};
|
||||
|
||||
serviceImplementation = {
|
||||
_dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'),
|
||||
_dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'),
|
||||
_create: () => console.warn('create() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super(UIDialogService.EVENTS);
|
||||
this.serviceImplementation = {
|
||||
...this.serviceImplementation,
|
||||
};
|
||||
}
|
||||
|
||||
public create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart = () => {},
|
||||
onDrag = () => {},
|
||||
onStop = () => {},
|
||||
centralize = false,
|
||||
preservePosition = true,
|
||||
isDraggable = true,
|
||||
showOverlay = false,
|
||||
defaultPosition = { x: 0, y: 0 },
|
||||
}) {
|
||||
return this.serviceImplementation._create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart,
|
||||
onDrag,
|
||||
onStop,
|
||||
centralize,
|
||||
preservePosition,
|
||||
isDraggable,
|
||||
showOverlay,
|
||||
defaultPosition,
|
||||
});
|
||||
}
|
||||
|
||||
public dismiss({ id }) {
|
||||
return this.serviceImplementation._dismiss({ id });
|
||||
}
|
||||
|
||||
public dismissAll() {
|
||||
return this.serviceImplementation._dismissAll();
|
||||
}
|
||||
|
||||
public setServiceImplementation({
|
||||
dismiss: dismissImplementation,
|
||||
dismissAll: dismissAllImplementation,
|
||||
create: createImplementation,
|
||||
}) {
|
||||
if (dismissImplementation) {
|
||||
this.serviceImplementation._dismiss = dismissImplementation;
|
||||
}
|
||||
if (dismissAllImplementation) {
|
||||
this.serviceImplementation._dismissAll = dismissAllImplementation;
|
||||
}
|
||||
if (createImplementation) {
|
||||
this.serviceImplementation._create = createImplementation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UIDialogService;
|
||||
3
platform/core/src/services/UIDialogService/index.ts
Normal file
3
platform/core/src/services/UIDialogService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import UIDialogService from './UIDialogService';
|
||||
|
||||
export default UIDialogService;
|
||||
90
platform/core/src/services/UIModalService/index.ts
Normal file
90
platform/core/src/services/UIModalService/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* UI Modal
|
||||
*
|
||||
* @typedef {Object} ModalProps
|
||||
* @property {ReactElement|HTMLElement} [content=null] Modal content.
|
||||
* @property {Object} [contentProps=null] Modal content props.
|
||||
* @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key.
|
||||
* @property {boolean} [isOpen=true] Make the Modal visible or hidden.
|
||||
* @property {boolean} [closeButton=true] Should the modal body render the close button.
|
||||
* @property {string} [title=null] Should the modal render the title independently of the body content.
|
||||
* @property {string} [customClassName=null] The custom class to style the modal.
|
||||
*/
|
||||
|
||||
const name = 'uiModalService';
|
||||
|
||||
const serviceImplementation = {
|
||||
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
|
||||
_show: () => console.warn('show() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
class UIModalService {
|
||||
static REGISTRATION = {
|
||||
name,
|
||||
altName: 'UIModalService',
|
||||
create: (): UIModalService => {
|
||||
return new UIModalService();
|
||||
},
|
||||
};
|
||||
|
||||
readonly name = name;
|
||||
|
||||
/**
|
||||
* Show a new UI modal;
|
||||
*
|
||||
* @param {ModalProps} props { content, contentProps, shouldCloseOnEsc, isOpen, closeButton, title, customClassName }
|
||||
*/
|
||||
show({
|
||||
content = null,
|
||||
contentProps = null,
|
||||
shouldCloseOnEsc = true,
|
||||
isOpen = true,
|
||||
closeButton = true,
|
||||
title = null,
|
||||
customClassName = null,
|
||||
movable = false,
|
||||
containerDimensions = null,
|
||||
contentDimensions = null,
|
||||
}) {
|
||||
return serviceImplementation._show({
|
||||
content,
|
||||
contentProps,
|
||||
shouldCloseOnEsc,
|
||||
isOpen,
|
||||
closeButton,
|
||||
title,
|
||||
customClassName,
|
||||
movable,
|
||||
containerDimensions,
|
||||
contentDimensions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides/dismisses the modal, if currently shown
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
hide() {
|
||||
return serviceImplementation._hide();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} {
|
||||
* hide: hideImplementation,
|
||||
* show: showImplementation,
|
||||
* }
|
||||
*/
|
||||
setServiceImplementation({ hide: hideImplementation, show: showImplementation }) {
|
||||
if (hideImplementation) {
|
||||
serviceImplementation._hide = hideImplementation;
|
||||
}
|
||||
if (showImplementation) {
|
||||
serviceImplementation._show = showImplementation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UIModalService;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user