This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View 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"
}

View 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 });
};

View 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

File diff suppressed because it is too large Load Diff

21
platform/core/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

136
platform/core/README.md Normal file
View 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]
[![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#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 -->

View File

@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View 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"
// ]
};

View 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": "*"
}
}

View 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);
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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];
}

View 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);
});
});

View 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]);
}

View 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);
});
});

View 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('\\');
}

View 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);
});
});

View 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;

View 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);
});
});

View 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;

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View 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 };

View File

@@ -0,0 +1,5 @@
export default {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};

View 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);
});
});
});

View 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;

View File

@@ -0,0 +1,8 @@
export default interface Hotkey {
commandName: string;
commandOptions?: Record<string, unknown>;
context?: string;
keys: string[];
label: string;
isEditable?: boolean;
}

View 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);
});
});
});

View 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;

View 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;

View 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';

View 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;

View 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;

View File

@@ -0,0 +1,4 @@
import hotkeyBindings from './hotkeyBindings';
import windowLevelPresets from './windowLevelPresets';
export { hotkeyBindings, windowLevelPresets };
export default { hotkeyBindings, windowLevelPresets };

View 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' },
};

View 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',
}

View File

@@ -0,0 +1,3 @@
import { TimingEnum } from './TimingEnum';
export { TimingEnum };

View File

@@ -0,0 +1,6 @@
// These should be overridden by the implementation
const errorHandler = {
getHTTPErrorHandler: () => null,
};
export default errorHandler;

View 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'
);
});
});
});

View 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);
}

View 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',
};

View 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 };

View 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;

View 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
View 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
View 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
View 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;

View 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,
},
},
};

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
import CineService from './CineService';
export default CineService;

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
import CustomizationService from './CustomizationService';
export default CustomizationService;

View 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[];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
import DicomMetadataStore from './DicomMetadataStore';
export { DicomMetadataStore };
export default DicomMetadataStore;

View File

@@ -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 };

View File

@@ -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);
}
}
}

View 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;

View File

@@ -0,0 +1,5 @@
import DisplaySetService from './DisplaySetService';
import { DisplaySetMessage, DisplaySetMessageList } from './DisplaySetMessage';
export default DisplaySetService;
export { DisplaySetMessage, DisplaySetMessageList };

View 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 };

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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 };

View File

@@ -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;
};

View File

@@ -0,0 +1 @@
export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription);

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import HangingProtocolService from './HangingProtocolService';
export default HangingProtocolService;

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -0,0 +1,8 @@
// Sorts an array by score
const sortByScore = arr => {
arr.sort((a, b) => {
return b.score - a.score;
});
};
export { sortByScore };

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
import MeasurementService from './MeasurementService';
export default MeasurementService;

View 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();
}
}

View File

@@ -0,0 +1,3 @@
import PanelService from './PanelService';
export default PanelService;

View 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;
}
}

View 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);
});
});
});

View 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);
}
});
}
}

View File

@@ -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 };

View File

@@ -0,0 +1,3 @@
import { StudyPrefetcherService } from './StudyPrefetcherService';
export { StudyPrefetcherService as default, StudyPrefetcherService };

View 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 });
}
}

View File

@@ -0,0 +1,3 @@
import ToolbarService from './ToolbarService';
export default ToolbarService;

View 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;
};

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import UIDialogService from './UIDialogService';
export default UIDialogService;

View 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