feat(microscopy): add dicom microscopy extension and mode (#3247)

* skeleton for dicom-microscopy extension

* skeleton for microscopy mode

* [feat] ported @radicalimaging/microscopy-dicom to OHIF's default extension

* [feat] ported microscopy mode from private repo

* added new definitions to the package.json

* webpack configuration update for microscopy extension

* register new icons for microscopy tools

* fixes to the microscopy extension and mode

* trivial error fix - typescript type import error

* link microscopy extension with default OHIF app plugin config

* demo config fix

* fix logs

* remove unsed imports

* [fix] loading of microscopy

* [fix] webworker script loading, normalizing denaturalized dataset

* found the latest version of dicom-microscopy-viewer that works with current OHIF extension : 0.35.2

* hide thumbnail pane by default, as we have issues with

* comments

* remove unused code

* [feat] microscopy - annotation selection

* [feat] microscopy - edit annotation label

* wip

* [bugfix] dicom-microscopy tool

* [bugfix] dicom microscopy annotations

* [fix] mixed-content blocking caused by BulkDataURI

* [fix] microscopy measurements panel - center button

* [fix] microscopy measurements panel - styling

* [fix] microscopy - controls

* fix local loading of microscopy

* fix local loading of dicom microscopy

* fix typo - indexof to indexOf

* [fix] remove unused icons

* remove commented out lines from webpack configuration

* platform/viewer/public/config/default.js - revert accidental changes

* [refactor] redirecting to microscopy mode on Local mode

* attribution to DMV and SLIM viewer

* [fix] code review feedbacks

* fix thumbnails

* [fix] microscopy - fix old publisher.publish() to PubSubService._broadcastEvent()

* [fix] interactions, webpack config, roi selection

* [feat] microscopy - remove select tool  from UI

* microscopy author

* [fix] saving and loading SR

* [bugfix] - missing publish() function in MicroscopyService, replace with _broadcastEvent

* remove author section from readme

* refactor SR saving feature

* fix webpack config after merge

* [bugfix] repeated import

* fix e2e

* webpack configuration

* hide "Create report" button for microscopy
This commit is contained in:
M.D 2023-04-27 20:53:52 -04:00 committed by GitHub
parent dd3944a87c
commit 075008c674
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
76 changed files with 5868 additions and 31 deletions

View File

@ -25,7 +25,7 @@ function transpileJavaScript(mode) {
// These are packages that are not transpiled to our lowest supported
// JS version (currently ES5). Most of these leverage ES6+ features,
// that we need to transpile to a different syntax.
exclude,
exclude: [/(codecs)/, /(dicomicc)/, exclude],
loader: 'babel-loader',
options: {
// Find babel.config.js in monorepo root

View File

@ -78,6 +78,7 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
},
},
module: {
noParse: [/(codec)/, /(dicomicc)/],
rules: [
transpileJavaScriptRule(mode),
loadWebWorkersRule,
@ -89,6 +90,10 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
},
},
cssToJavaScript,
{
test: /\.wasm/,
type: 'asset/resource',
},
], //.concat(vtkRules),
},
resolve: {
@ -103,6 +108,8 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
'@hooks': path.resolve(__dirname, '../platform/viewer/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/viewer/src/routes'),
'@state': path.resolve(__dirname, '../platform/viewer/src/state'),
'dicom-microscopy-viewer':
'dicom-microscopy-viewer/dist/dynamic-import/dicomMicroscopyViewer.min.js',
'@cornerstonejs/dicom-image-loader':
'@cornerstonejs/dicom-image-loader/dist/dynamic-import/cornerstoneDICOMImageLoader.min.js',
},

View File

@ -28,7 +28,7 @@ import { registerColormap } from './utils/colormap/transferFunctionHelpers';
import { id } from './id';
import * as csWADOImageLoader from './initWADOImageLoader.js';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import { PublicViewportOptions } from './services/ViewportService/Viewport';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
const Component = React.lazy(() => {
return import(

View File

@ -321,6 +321,7 @@ function _mapDisplaySets(displaySets, thumbnailImageSrcMap) {
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',

104
extensions/dicom-microscopy/.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

View File

@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"printWidth": 80,
"proseWrap": "always",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

View File

@ -0,0 +1,8 @@
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');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};

View File

@ -0,0 +1,63 @@
const path = require('path');
const pkg = require('../package.json');
const outputFile = 'index.umd.js';
const rootDir = path.resolve(__dirname, '../');
const outputFolder = path.join(__dirname, `../dist/umd/${pkg.name}/`);
// Todo: add ESM build for the extension in addition to umd build
const config = {
mode: 'production',
entry: rootDir + '/' + pkg.module,
devtool: 'source-map',
output: {
path: outputFolder,
filename: outputFile,
library: pkg.name,
libraryTarget: 'umd',
chunkFilename: '[name].chunk.js',
umdNamedDefine: true,
globalObject: "typeof self !== 'undefined' ? self : this",
},
externals: [
{
react: {
root: 'React',
commonjs2: 'react',
commonjs: 'react',
amd: 'react',
},
'@ohif/core': {
commonjs2: '@ohif/core',
commonjs: '@ohif/core',
amd: '@ohif/core',
root: '@ohif/core',
},
'@ohif/ui': {
commonjs2: '@ohif/ui',
commonjs: '@ohif/ui',
amd: '@ohif/ui',
root: '@ohif/ui',
},
},
],
module: {
rules: [
{
test: /(\.jsx|\.js|\.tsx|\.ts)$/,
loader: 'babel-loader',
exclude: /(node_modules|bower_components)/,
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
],
},
resolve: {
modules: [path.resolve('./node_modules'), path.resolve('./src')],
extensions: ['.json', '.js', '.jsx', '.tsx', '.ts'],
},
};
module.exports = config;

View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023 dicom-microscopy (26860200+md-prog@users.noreply.github.com)
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.

View File

@ -0,0 +1,11 @@
# OHIF extension for microscopy
Adapter for *DICOM Microscopy Viewer* to get it integrated into OHIF Viewer.
## Acknowledgements
- [DICOM Microscopy Viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) is a Vanilla JS library for web-based visualization of DICOM VL Whole Slide Microscopy Image datasets and derived information.
- [SLIM Viewer](https://github.com/imagingdatacommons/slim) is a single-page application for interactive visualization and annotation of digital whole slide microscopy images and derived image analysis results in standard DICOM format. The application is based on the dicom-microscopy-viewer JavaScript library and runs fully client side without any custom server components.
## License
MIT

View File

@ -0,0 +1,44 @@
module.exports = {
plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
[
// TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2
'@babel/preset-env',
{
modules: 'commonjs',
debug: false,
},
"@babel/preset-typescript",
],
'@babel/preset-react',
],
plugins: [
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
],
},
production: {
presets: [
// WebPack handles ES6 --> Target Syntax
['@babel/preset-env', { modules: false }],
'@babel/preset-react',
"@babel/preset-typescript",
],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
development: {
presets: [
// WebPack handles ES6 --> Target Syntax
['@babel/preset-env', { modules: false }],
'@babel/preset-react',
"@babel/preset-typescript",
],
plugins: ['react-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
};

View File

@ -0,0 +1,79 @@
{
"name": "@ohif/extension-dicom-microscopy",
"version": "3.0.0",
"description": "OHIF extension for DICOM microscopy",
"author": "Bill Wallace, md-prog",
"license": "MIT",
"main": "dist/umd/@ohif/extension-dicom-microscopy/index.umd.js",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-extension"
],
"module": "src/index.tsx",
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:dicom-pdf": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/extension-default": "^3.0.0",
"@ohif/i18n": "^1.0.0",
"@ohif/ui": "^2.0.0",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^10.11.0",
"react-router": "^6.8.1",
"react-router-dom": "^6.8.1",
"webpack": "^5.50.0",
"webpack-merge": "^5.7.3"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/codec-charls": "^0.1.1",
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^0.0.7",
"@cornerstonejs/codec-openjpeg": "^0.1.1",
"colormap": "^2.3",
"dicom-microscopy-viewer": "^0.44.0",
"dicomicc": "^0.1"
},
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-object-rest-spread": "^7.17.3",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-arrow-functions": "^7.16.7",
"@babel/plugin-transform-regenerator": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/plugin-transform-typescript": "^7.13.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.13.0",
"babel-eslint": "^8.0.3",
"babel-loader": "^8.0.0-beta.4",
"babel-plugin-inline-react-svg": "^2.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^10.2.0",
"cross-env": "^7.0.3",
"dotenv": "^14.1.0",
"eslint": "^5.0.1",
"eslint-loader": "^2.0.0",
"webpack": "^5.50.0",
"webpack-cli": "^4.7.2",
"webpack-merge": "^5.7.3"
}
}

View File

@ -0,0 +1,143 @@
import OHIF, { DicomMetadataStore } from '@ohif/core';
import loadSR from './utils/loadSR';
import toArray from './utils/toArray';
import DCM_CODE_VALUES from './utils/dcmCodeValues';
import getSourceDisplaySet from './utils/getSourceDisplaySet';
const { utils } = OHIF;
const SOP_CLASS_UIDS = {
COMPREHENSIVE_3D_SR: '1.2.840.10008.5.1.4.1.1.88.34',
};
const SOPClassHandlerId =
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler';
function _getReferencedFrameOfReferenceUID(naturalizedDataset) {
const { ContentSequence } = naturalizedDataset;
const imagingMeasurementsContentItem = ContentSequence.find(
ci =>
ci.ConceptNameCodeSequence.CodeValue ===
DCM_CODE_VALUES.IMAGING_MEASUREMENTS
);
const firstMeasurementGroupContentItem = toArray(
imagingMeasurementsContentItem.ContentSequence
).find(
ci =>
ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP
);
const imageRegionContentItem = toArray(
firstMeasurementGroupContentItem.ContentSequence
).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.IMAGE_REGION
);
return imageRegionContentItem.ReferencedFrameOfReferenceUID;
}
function _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const { displaySetService, microscopyService } = servicesManager.services;
const instance = instances[0];
// TODO ! Consumption of DICOMMicroscopySRSOPClassHandler to a derived dataset or normal dataset?
// TOOD -> Easy to swap this to a "non-derived" displaySet, but unfortunately need to put it in a different extension.
const naturalizedDataset = DicomMetadataStore.getSeries(
instance.StudyInstanceUID,
instance.SeriesInstanceUID
).instances[0];
const ReferencedFrameOfReferenceUID = _getReferencedFrameOfReferenceUID(
naturalizedDataset
);
const {
FrameOfReferenceUID,
SeriesDescription,
ContentDate,
ContentTime,
SeriesNumber,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SOPClassUID,
} = instance;
const displaySet = {
plugin: 'microscopy',
Modality: 'SR',
altImageText: 'Microscopy SR',
displaySetInstanceUID: utils.guid(),
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
ReferencedFrameOfReferenceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription,
// Map the content date/time to the series date/time, these are only used for filtering.
SeriesDate: ContentDate,
SeriesTime: ContentTime,
SeriesNumber,
instance,
metadata: naturalizedDataset,
isDerived: true,
isLoading: false,
isLoaded: false,
loadError: false,
};
displaySet.load = function(referencedDisplaySet) {
return loadSR(microscopyService, displaySet, referencedDisplaySet).catch(
error => {
displaySet.isLoaded = false;
displaySet.loadError = true;
throw new Error(error);
}
);
};
displaySet.getSourceDisplaySet = function() {
let allDisplaySets = [];
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
studyMetadata.series.forEach(series => {
const displaySets = displaySetService.getDisplaySetsForSeries(
series.SeriesInstanceUID
);
allDisplaySets = allDisplaySets.concat(displaySets);
});
return getSourceDisplaySet(allDisplaySets, displaySet);
};
return [displaySet];
}
export default function getDicomMicroscopySRSopClassHandler({
servicesManager,
extensionManager,
}) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
);
};
return {
name: 'DicomMicroscopySRSopClassHandler',
sopClassUids: [SOP_CLASS_UIDS.COMPREHENSIVE_3D_SR],
getDisplaySetsFromSeries,
};
}

View File

@ -0,0 +1,132 @@
import OHIF from '@ohif/core';
const { utils } = OHIF;
const SOP_CLASS_UIDS = {
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
};
const SOPClassHandlerId =
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler';
function _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const instance = instances[0];
let singleFrameInstance = instance;
let currentFrames = +singleFrameInstance.NumberOfFrames || 1;
for (const instanceI of instances) {
const framesI = +instanceI.NumberOfFrames || 1;
if (framesI < currentFrames) {
singleFrameInstance = instanceI;
currentFrames = framesI;
}
}
let imageIdForThumbnail = null;
if (singleFrameInstance) {
if (currentFrames == 1) {
// Not all DICOM server implementations support thumbnail service,
// So if we have a single-frame image, we will prefer it.
imageIdForThumbnail = singleFrameInstance.imageId;
}
if (!imageIdForThumbnail) {
// use the thumbnail service provided by DICOM server
const dataSource = extensionManager.getActiveDataSource()[0];
imageIdForThumbnail = dataSource.getImageIdsForInstance({
instance: singleFrameInstance,
thumbnail: true,
});
}
}
const {
FrameOfReferenceUID,
SeriesDescription,
ContentDate,
ContentTime,
SeriesNumber,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SOPClassUID,
} = instance;
instances = instances.map(inst => {
// NOTE: According to DICOM standard a series should have a FrameOfReferenceUID
// When the Microscopy file was built by certain tool from multiple image files,
// each instance's FrameOfReferenceUID is sometimes different.
// Even though this means the file was not well formatted DICOM VL Whole Slide Microscopy Image,
// the case is so often, so let's override this value manually here.
//
// https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.7.4.html#sect_C.7.4.1.1.1
inst.FrameOfReferenceUID = instance.FrameOfReferenceUID;
return inst;
});
const othersFrameOfReferenceUID = instances
.filter(v => v)
.map(inst => inst.FrameOfReferenceUID)
.filter((value, index, array) => array.indexOf(value) === index);
if (othersFrameOfReferenceUID.length > 1) {
console.warn(
'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values',
othersFrameOfReferenceUID
);
}
const displaySet = {
plugin: 'microscopy',
Modality: 'SM',
altImageText: 'Microscopy',
displaySetInstanceUID: utils.guid(),
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
FrameOfReferenceUID,
SOPClassHandlerId,
SOPClassUID,
SeriesDescription: SeriesDescription || 'Microscopy Data',
// Map ContentDate/Time to SeriesTime for series list sorting.
SeriesDate: ContentDate,
SeriesTime: ContentTime,
SeriesNumber,
firstInstance: singleFrameInstance, // top level instance in the image Pyramid
instance,
numImageFrames: 0,
numInstances: 1,
imageIdForThumbnail, // thumbnail image
others: instances, // all other level instances in the image Pyramid
othersFrameOfReferenceUID,
};
return [displaySet];
}
export default function getDicomMicroscopySopClassHandler({
servicesManager,
extensionManager,
}) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
);
};
return {
name: 'DicomMicroscopySopClassHandler',
sopClassUids: [SOP_CLASS_UIDS.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE],
getDisplaySetsFromSeries,
};
}

View File

@ -0,0 +1,352 @@
.DicomMicroscopyViewer .ol-box {
box-sizing: border-box;
border-radius: 2px;
border: 1.5px solid var(--ol-background-color);
background-color: var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-mouse-position {
top: 8px;
right: 8px;
position: absolute;
}
.DicomMicroscopyViewer .ol-scale-line {
background: var(--ol-partial-background-color);
border-radius: 4px;
bottom: 8px;
left: 8px;
padding: 2px;
position: absolute;
}
.DicomMicroscopyViewer .ol-scale-line-inner {
border: 1px solid var(--ol-subtle-foreground-color);
border-top: none;
color: var(--ol-foreground-color);
font-size: 10px;
text-align: center;
margin: 1px;
will-change: contents, width;
transition: all 0.25s;
}
.DicomMicroscopyViewer .ol-scale-bar {
position: absolute;
bottom: 8px;
left: 8px;
}
.DicomMicroscopyViewer .ol-scale-bar-inner {
display: flex;
}
.DicomMicroscopyViewer .ol-scale-step-marker {
width: 1px;
height: 15px;
background-color: var(--ol-foreground-color);
float: right;
z-index: 10;
}
.DicomMicroscopyViewer .ol-scale-step-text {
position: absolute;
bottom: -5px;
font-size: 10px;
z-index: 11;
color: var(--ol-foreground-color);
text-shadow: -1.5px 0 var(--ol-partial-background-color),
0 1.5px var(--ol-partial-background-color),
1.5px 0 var(--ol-partial-background-color),
0 -1.5px var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-scale-text {
position: absolute;
font-size: 12px;
text-align: center;
bottom: 25px;
color: var(--ol-foreground-color);
text-shadow: -1.5px 0 var(--ol-partial-background-color),
0 1.5px var(--ol-partial-background-color),
1.5px 0 var(--ol-partial-background-color),
0 -1.5px var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar {
position: relative;
height: 10px;
z-index: 9;
box-sizing: border-box;
border: 1px solid var(--ol-foreground-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar-even {
background-color: var(--ol-subtle-foreground-color);
}
.DicomMicroscopyViewer .ol-scale-singlebar-odd {
background-color: var(--ol-background-color);
}
.DicomMicroscopyViewer .ol-unsupported {
display: none;
}
.DicomMicroscopyViewer .ol-viewport,
.DicomMicroscopyViewer .ol-unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.DicomMicroscopyViewer .ol-viewport canvas {
all: unset;
}
.DicomMicroscopyViewer .ol-selectable {
-webkit-touch-callout: default;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
.DicomMicroscopyViewer .ol-grabbing {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
.DicomMicroscopyViewer .ol-grab {
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.DicomMicroscopyViewer .ol-control {
position: absolute;
background-color: var(--ol-subtle-background-color);
border-radius: 4px;
}
.DicomMicroscopyViewer .ol-zoom {
top: 0.5em;
left: 0.5em;
}
.DicomMicroscopyViewer .ol-rotate {
top: 0.5em;
right: 0.5em;
transition: opacity 0.25s linear, visibility 0s linear;
}
.DicomMicroscopyViewer .ol-rotate.ol-hidden {
opacity: 0;
visibility: hidden;
transition: opacity 0.25s linear, visibility 0s linear 0.25s;
}
.DicomMicroscopyViewer .ol-zoom-extent {
top: 4.643em;
left: 0.5em;
}
.DicomMicroscopyViewer .ol-full-screen {
right: 0.5em;
top: 0.5em;
}
.DicomMicroscopyViewer .ol-control button {
display: block;
margin: 1px;
padding: 0;
color: var(--ol-subtle-foreground-color);
font-weight: bold;
text-decoration: none;
font-size: inherit;
text-align: center;
height: 1.375em;
width: 1.375em;
line-height: 0.4em;
background-color: var(--ol-background-color);
border: none;
border-radius: 2px;
}
.DicomMicroscopyViewer .ol-control button::-moz-focus-inner {
border: none;
padding: 0;
}
.DicomMicroscopyViewer .ol-zoom-extent button {
line-height: 1.4em;
}
.DicomMicroscopyViewer .ol-compass {
display: block;
font-weight: normal;
will-change: transform;
}
.DicomMicroscopyViewer .ol-touch .ol-control button {
font-size: 1.5em;
}
.DicomMicroscopyViewer .ol-touch .ol-zoom-extent {
top: 5.5em;
}
.DicomMicroscopyViewer .ol-control button:hover,
.DicomMicroscopyViewer .ol-control button:focus {
text-decoration: none;
outline: 1px solid var(--ol-subtle-foreground-color);
color: var(--ol-foreground-color);
}
.DicomMicroscopyViewer .ol-zoom .ol-zoom-in {
border-radius: 2px 2px 0 0;
}
.DicomMicroscopyViewer .ol-zoom .ol-zoom-out {
border-radius: 0 0 2px 2px;
}
.DicomMicroscopyViewer .ol-attribution {
text-align: right;
bottom: 0.5em;
right: 0.5em;
max-width: calc(100% - 1.3em);
display: flex;
flex-flow: row-reverse;
align-items: center;
}
.DicomMicroscopyViewer .ol-attribution a {
color: var(--ol-subtle-foreground-color);
text-decoration: none;
}
.DicomMicroscopyViewer .ol-attribution ul {
margin: 0;
padding: 1px 0.5em;
color: var(--ol-foreground-color);
text-shadow: 0 0 2px var(--ol-background-color);
font-size: 12px;
}
.DicomMicroscopyViewer .ol-attribution li {
display: inline;
list-style: none;
}
.DicomMicroscopyViewer .ol-attribution li:not(:last-child):after {
content: ' ';
}
.DicomMicroscopyViewer .ol-attribution img {
max-height: 2em;
max-width: inherit;
vertical-align: middle;
}
.DicomMicroscopyViewer .ol-attribution button {
flex-shrink: 0;
}
.DicomMicroscopyViewer .ol-attribution.ol-collapsed ul {
display: none;
}
.DicomMicroscopyViewer .ol-attribution:not(.ol-collapsed) {
background: var(--ol-partial-background-color);
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible {
bottom: 0;
right: 0;
border-radius: 4px 0 0;
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible img {
margin-top: -0.2em;
max-height: 1.6em;
}
.DicomMicroscopyViewer .ol-attribution.ol-uncollapsible button {
display: none;
}
.DicomMicroscopyViewer .ol-zoomslider {
top: 4.5em;
left: 0.5em;
height: 200px;
}
.DicomMicroscopyViewer .ol-zoomslider button {
position: relative;
height: 10px;
}
.DicomMicroscopyViewer .ol-touch .ol-zoomslider {
top: 5.5em;
}
.DicomMicroscopyViewer .ol-overviewmap {
left: 0.5em;
bottom: 0.5em;
}
.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible {
bottom: 0;
left: 0;
border-radius: 0 4px 0 0;
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map,
.DicomMicroscopyViewer .ol-overviewmap button {
display: block;
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-map {
border: 1px solid var(--ol-subtle-foreground-color);
height: 150px;
width: 150px;
}
.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) button {
bottom: 0;
left: 0;
position: absolute;
}
.DicomMicroscopyViewer .ol-overviewmap.ol-collapsed .ol-overviewmap-map,
.DicomMicroscopyViewer .ol-overviewmap.ol-uncollapsible button {
display: none;
}
.DicomMicroscopyViewer .ol-overviewmap:not(.ol-collapsed) {
background: var(--ol-subtle-background-color);
}
.DicomMicroscopyViewer .ol-overviewmap-box {
border: 1.5px dotted var(--ol-subtle-foreground-color);
}
.DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-box:hover {
cursor: move;
}
@layout-header-background: #007ea3;
@primary-color: #007ea3;
@processing-color: #8cb8c6;
@success-color: #3f9c35;
@warning-color: #eeaf30;
@error-color: #96172e;
@font-size-base: 14px;
.DicomMicroscopyViewer .ol-tooltip {
font-size: 16px !important;
}

View File

@ -0,0 +1,321 @@
import React, { Component } from 'react';
import ReactResizeDetector from 'react-resize-detector';
import PropTypes from 'prop-types';
import debounce from 'lodash.debounce';
import './DicomMicroscopyViewport.css';
import ViewportOverlay from './components/ViewportOverlay';
import getDicomWebClient from './utils/dicomWebClient';
import dcmjs from 'dcmjs';
import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset';
import MicroscopyService from './services/MicroscopyService';
class DicomMicroscopyViewport extends Component {
state = {
error: null as any,
};
microscopyService: MicroscopyService;
viewer: any = null; // dicom-microscopy-viewer instance
managedViewer: any = null; // managed wrapper of microscopy-dicom extension
container = React.createRef();
overlayElement = React.createRef();
debouncedResize: () => any;
constructor(props: any) {
super(props);
const { microscopyService } = this.props.servicesManager.services;
this.microscopyService = microscopyService;
this.debouncedResize = debounce(() => {
if (this.viewer) this.viewer.resize();
}, 100);
}
static propTypes = {
viewportData: PropTypes.object,
activeViewportIndex: PropTypes.number,
setViewportActive: PropTypes.func,
// props from OHIF Viewport Grid
displaySets: PropTypes.array,
viewportIndex: PropTypes.number,
viewportLabel: PropTypes.string,
dataSource: PropTypes.object,
viewportOptions: PropTypes.object,
displaySetOptions: PropTypes.array,
// other props from wrapping component
servicesManager: PropTypes.object,
extensionManager: PropTypes.object,
commandsManager: PropTypes.object,
};
/**
* Get the nearest ROI from the mouse click point
*
* @param event
* @param autoselect
* @returns
*/
getNearbyROI(event: Event, autoselect = true) {
const symbols = Object.getOwnPropertySymbols(this.viewer);
const _drawingSource = symbols.find(p => p.description === 'drawingSource');
const _pyramid = symbols.find(p => p.description === 'pyramid');
const _map = symbols.find(p => p.description === 'map');
const _affine = symbols.find(p => p.description === 'affine');
const feature = this.viewer[_drawingSource].getClosestFeatureToCoordinate(
this.viewer[_map].getEventCoordinate(event)
);
if (!feature) {
return null;
}
const roiAnnotation = this.viewer._getROIFromFeature(
feature,
this.viewer[_pyramid].metadata,
this.viewer[_affine]
);
if (roiAnnotation && autoselect) {
this.microscopyService.selectAnnotation(roiAnnotation);
}
return roiAnnotation;
}
// install the microscopy renderer into the web page.
// you should only do this once.
async installOpenLayersRenderer(container, displaySet) {
const loadViewer = async metadata => {
const {
viewer: DicomMicroscopyViewer,
metadata: metadataUtils,
} = await import(
/* webpackChunkName: "dicom-microscopy-viewer" */ 'dicom-microscopy-viewer'
);
const microscopyViewer = DicomMicroscopyViewer.VolumeImageViewer;
const client = getDicomWebClient({
extensionManager: this.props.extensionManager,
servicesManager: this.props.servicesManager,
});
// Parse, format, and filter metadata
const volumeImages: any[] = [];
/**
* This block of code is the original way of loading DICOM into dicom-microscopy-viewer
* as in their documentation.
* But we have the metadata already loaded by our loaders.
* As the metadata for microscopy DIOM files tends to be big and we don't
* want to double load it, below we have the mechanism to reconstruct the
* DICOM JSON structure (denaturalized) from naturalized metadata.
* (NOTE: Our loaders cache only naturalized metadata, not the denaturalized.)
*/
// {
// const retrieveOptions = {
// studyInstanceUID: metadata[0].StudyInstanceUID,
// seriesInstanceUID: metadata[0].SeriesInstanceUID,
// };
// metadata = await client.retrieveSeriesMetadata(retrieveOptions);
// // Parse, format, and filter metadata
// metadata.forEach(m => {
// if (
// volumeImages.length > 0 &&
// m['00200052'].Value[0] != volumeImages[0].FrameOfReferenceUID
// ) {
// console.warn(
// 'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values',
// m['00200052'].Value[0]
// );
// m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID;
// }
// const image = new metadataUtils.VLWholeSlideMicroscopyImage({
// metadata: m,
// });
// const imageFlavor = image.ImageType[2];
// if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') {
// volumeImages.push(image);
// }
// });
// }
metadata.forEach(m => {
const inst = cleanDenaturalizedDataset(
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m)
);
if (!inst['00480105']) {
// Optical Path Sequence, no OpticalPathIdentifier?
// NOTE: this is actually a not-well formatted DICOM VL Whole Slide Microscopy Image.
inst['00480105'] = {
vr: 'SQ',
Value: [
{
'00480106': {
vr: 'SH',
Value: ['1'],
},
},
],
};
}
const image = new metadataUtils.VLWholeSlideMicroscopyImage({
metadata: inst,
});
const imageFlavor = image.ImageType[2];
if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') {
volumeImages.push(image);
}
});
// format metadata for microscopy-viewer
const options = {
client,
metadata: volumeImages,
retrieveRendered: false,
controls: ['overview', 'position', 'zoom'],
};
this.viewer = new microscopyViewer(options);
if (
this.overlayElement &&
this.overlayElement.current &&
this.viewer.addViewportOverlay
) {
this.viewer.addViewportOverlay({
element: this.overlayElement.current,
coordinates: [0, 0], // TODO: dicom-microscopy-viewer documentation says this can be false to be automatically, but it is not.
navigate: true,
className: 'OpenLayersOverlay',
});
}
this.viewer.render({ container });
const { StudyInstanceUID, SeriesInstanceUID } = displaySet;
this.managedViewer = this.microscopyService.addViewer(
this.viewer,
this.props.viewportIndex,
container,
StudyInstanceUID,
SeriesInstanceUID
);
this.managedViewer.addContextMenuCallback((event: Event) => {
// TODO: refactor this after Bill's changes on ContextMenu feature get merged
// const roiAnnotationNearBy = this.getNearbyROI(event);
});
};
this.microscopyService.clearAnnotations();
let smDisplaySet = displaySet;
if (displaySet.Modality === 'SR') {
// for SR displaySet, let's load the actual image displaySet
smDisplaySet = displaySet.getSourceDisplaySet();
}
console.log('Loading viewer metadata', smDisplaySet);
await loadViewer(smDisplaySet.others);
if (displaySet.Modality === 'SR') {
displaySet.load(smDisplaySet);
}
}
componentDidMount() {
const { displaySets, viewportIndex } = this.props;
const displaySet = displaySets[viewportIndex];
this.installOpenLayersRenderer(this.container.current, displaySet);
}
componentDidUpdate(
prevProps: Readonly<{}>,
prevState: Readonly<{}>,
snapshot?: any
): void {
if (
this.managedViewer &&
prevProps.displaySets !== this.props.displaySets
) {
const { displaySets } = this.props;
const displaySet = displaySets[0];
this.microscopyService.clearAnnotations();
// loading SR
if (displaySet.Modality === 'SR') {
const referencedDisplaySet = displaySet.getSourceDisplaySet();
displaySet.load(referencedDisplaySet);
}
}
}
componentWillUnmount() {
this.microscopyService.removeViewer(this.viewer);
}
setViewportActiveHandler = () => {
const {
setViewportActive,
viewportIndex,
activeViewportIndex,
} = this.props;
if (viewportIndex !== activeViewportIndex) {
setViewportActive(viewportIndex);
}
};
render() {
const style = { width: '100%', height: '100%' };
const displaySet = this.props.displaySets[0];
const firstInstance = displaySet.firstInstance || displaySet.instance;
return (
<div
className={'DicomMicroscopyViewer'}
style={style}
onClick={this.setViewportActiveHandler}
>
<div style={{ ...style, display: 'none' }}>
<div style={{ ...style }} ref={this.overlayElement}>
<div
style={{ position: 'relative', height: '100%', width: '100%' }}
>
{displaySet && firstInstance.imageId && (
<ViewportOverlay
displaySet={displaySet}
instance={displaySet.instance}
metadata={displaySet.metadata}
/>
)}
</div>
</div>
</div>
{ReactResizeDetector && (
<ReactResizeDetector
handleWidth
handleHeight
onResize={this.onWindowResize}
/>
)}
{this.state.error ? (
<h2>{JSON.stringify(this.state.error)}</h2>
) : (
<div style={style} ref={this.container} />
)}
</div>
);
}
onWindowResize = () => {
this.debouncedResize();
};
}
export default DicomMicroscopyViewport;

View File

@ -0,0 +1,413 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
ServicesManager,
ExtensionManager,
CommandsManager,
DicomMetadataStore,
} from '@ohif/core';
import { MeasurementTable, Icon, ButtonGroup, Button } from '@ohif/ui';
import { withTranslation, WithTranslation } from 'react-i18next';
import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService';
import dcmjs from 'dcmjs';
import styles from '../../utils/styles';
import callInputDialog from '../../utils/callInputDialog';
import constructSR from '../../utils/constructSR';
import { saveByteArray } from '../../utils/saveByteArray';
let saving = false;
const { datasetToBuffer } = dcmjs.data;
const formatArea = area => {
let mult = 1;
let unit = 'mm';
if (area > 1000000) {
unit = 'm';
mult = 1 / 1000000;
} else if (area < 1) {
unit = 'μm';
mult = 1000000;
}
return `${(area * mult).toFixed(2)} ${unit}²`;
};
const formatLength = (length, unit) => {
let mult = 1;
if (unit == 'km' || (!unit && length > 1000000)) {
unit = 'km';
mult = 1 / 1000000;
} else if (unit == 'm' || (!unit && length > 1000)) {
unit = 'm';
mult = 1 / 1000;
} else if (unit == 'μm' || (!unit && length < 1)) {
unit = 'μm';
mult = 1000;
} else if (unit && unit != 'mm') {
throw new Error(`Unknown length unit ${unit}`);
} else {
unit = 'mm';
}
return `${(length * mult).toFixed(2)} ${unit}`;
};
interface IMicroscopyPanelProps extends WithTranslation {
viewports: PropTypes.array;
activeViewportIndex: PropTypes.number;
//
onSaveComplete?: PropTypes.func; // callback when successfully saved annotations
onRejectComplete?: PropTypes.func; // callback when rejected annotations
//
servicesManager: ServicesManager;
extensionManager: ExtensionManager;
commandsManager: CommandsManager;
}
/**
* Microscopy Measurements Panel Component
*
* @param props
* @returns
*/
function MicroscopyPanel(props: IMicroscopyPanelProps) {
const { microscopyService } = props.servicesManager.services;
const [studyInstanceUID, setStudyInstanceUID] = useState(
null as string | null
);
const [roiAnnotations, setRoiAnnotations] = useState([] as any[]);
const [selectedAnnotation, setSelectedAnnotation] = useState(null as any);
const { servicesManager, extensionManager } = props;
const { uiDialogService, displaySetService } = servicesManager.services;
useEffect(() => {
const viewport = props.viewports[props.activeViewportIndex];
if (viewport.displaySetInstanceUIDs[0]) {
const displaySet = displaySetService.getDisplaySetByUID(
viewport.displaySetInstanceUIDs[0]
);
if (displaySet) {
setStudyInstanceUID(displaySet.StudyInstanceUID);
}
}
}, [props.viewports, props.activeViewportIndex]);
useEffect(() => {
const onAnnotationUpdated = () => {
const roiAnnotations = microscopyService.getAnnotationsForStudy(
studyInstanceUID
);
setRoiAnnotations(roiAnnotations);
};
const onAnnotationSelected = () => {
const selectedAnnotation = microscopyService.getSelectedAnnotation();
setSelectedAnnotation(selectedAnnotation);
};
const onAnnotationRemoved = () => {
onAnnotationUpdated();
};
const {
unsubscribe: unsubscribeAnnotationUpdated,
} = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_UPDATED,
onAnnotationUpdated
);
const {
unsubscribe: unsubscribeAnnotationSelected,
} = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_SELECTED,
onAnnotationSelected
);
const {
unsubscribe: unsubscribeAnnotationRemoved,
} = microscopyService.subscribe(
MicroscopyEvents.ANNOTATION_REMOVED,
onAnnotationRemoved
);
onAnnotationUpdated();
onAnnotationSelected();
// on unload unsubscribe from events
return () => {
unsubscribeAnnotationUpdated();
unsubscribeAnnotationSelected();
unsubscribeAnnotationRemoved();
};
}, [studyInstanceUID]);
/**
* On clicking "Save Annotations" button, prompt an input modal for the
* new series' description, and continue to save.
*
* @returns
*/
const promptSave = () => {
const annotations = microscopyService.getAnnotationsForStudy(
studyInstanceUID
);
if (!annotations || saving) {
return;
}
callInputDialog({
uiDialogService,
title: 'Enter description of the Series',
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
saveFunction(value);
}
}
},
});
};
const getAllDisplaySets = (studyMetadata: any) => {
let allDisplaySets = [] as any[];
studyMetadata.series.forEach((series: any) => {
const displaySets = displaySetService.getDisplaySetsForSeries(
series.SeriesInstanceUID
);
allDisplaySets = allDisplaySets.concat(displaySets);
});
return allDisplaySets;
};
/**
* Save annotations as a series
*
* @param SeriesDescription - series description
* @returns
*/
const saveFunction = async (SeriesDescription: string) => {
const dataSource = extensionManager.getActiveDataSource()[0];
const { onSaveComplete } = props;
const annotations = microscopyService.getAnnotationsForStudy(
studyInstanceUID
);
saving = true;
// There is only one viewer possible for one study,
// Since once study contains multiple resolution levels (series) of one whole
// Slide image.
const studyMetadata = DicomMetadataStore.getStudy(studyInstanceUID);
const displaySets = getAllDisplaySets(studyMetadata);
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
// Get the next available series number after 4700.
const dsWithMetadata = displaySets.filter(
ds =>
ds.metadata &&
ds.metadata.SeriesNumber &&
typeof ds.metadata.SeriesNumber === 'number'
);
// Generate next series number
const seriesNumbers = dsWithMetadata.map(ds => ds.metadata.SeriesNumber);
const maxSeriesNumber = Math.max(...seriesNumbers, 4700);
const SeriesNumber = maxSeriesNumber + 1;
const { instance: metadata } = smDisplaySet;
// construct SR dataset
const dataset = constructSR(
metadata,
{ SeriesDescription, SeriesNumber },
annotations
);
// Save in DICOM format
try {
if (dataSource) {
if (dataSource.wadoRoot == 'saveDicom') {
// download as DICOM file
const part10Buffer = datasetToBuffer(dataset);
saveByteArray(part10Buffer, `sr-microscopy.dcm`);
} else {
// Save into Web Data source
const { StudyInstanceUID } = dataset;
await dataSource.store.dicom(dataset);
if (StudyInstanceUID) {
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
}
}
onSaveComplete({
title: 'SR Saved',
meassage: 'Measurements downloaded successfully',
type: 'success',
});
} else {
console.error('Server unspecified');
}
} catch (error) {
onSaveComplete({
title: 'SR Save Failed',
message: error.message || error.toString(),
type: 'error',
});
} finally {
saving = false;
}
};
/**
* On clicking "Reject annotations" button
*/
const onDeleteCurrentSRHandler = async () => {
try {
const activeViewport = props.viewports[props.activeViewportIndex];
const { StudyInstanceUID } = activeViewport;
// TODO: studies?
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
const lastDerivedDisplaySet = study.derivedDisplaySets.sort(
(ds1: any, ds2: any) => {
const dateTime1 = Number(`${ds1.SeriesDate}${ds1.SeriesTime}`);
const dateTime2 = Number(`${ds2.SeriesDate}${ds2.SeriesTime}`);
return dateTime1 > dateTime2;
}
)[study.derivedDisplaySets.length - 1];
// TODO: use dataSource.reject.dicom()
// await DICOMSR.rejectMeasurements(
// study.wadoRoot,
// lastDerivedDisplaySet.StudyInstanceUID,
// lastDerivedDisplaySet.SeriesInstanceUID
// );
props.onRejectComplete({
title: 'Report rejected',
message: 'Latest report rejected successfully',
type: 'success',
});
} catch (error) {
props.onRejectComplete({
title: 'Failed to reject report',
message: error.message,
type: 'error',
});
}
};
/**
* Handler for clicking event of an annotation item.
*
* @param param0
*/
const onMeasurementItemClickHandler = ({ uid }: { uid: string }) => {
const roiAnnotation = microscopyService.getAnnotation(uid);
microscopyService.selectAnnotation(roiAnnotation);
microscopyService.focusAnnotation(roiAnnotation, props.activeViewportIndex);
};
/**
* Handler for "Edit" action of an annotation item
* @param param0
*/
const onMeasurementItemEditHandler = ({
uid,
isActive,
}: {
uid: string;
isActive: boolean;
}) => {
props.commandsManager.runCommand('setLabel', { uid }, 'MICROSCOPY');
};
// Convert ROI annotations managed by microscopyService into our
// own format for display
const data = roiAnnotations.map((roiAnnotation, index) => {
const label = roiAnnotation.getDetailedLabel();
const area = roiAnnotation.getArea();
const length = roiAnnotation.getLength();
const shortAxisLength = roiAnnotation.roiGraphic.properties.shortAxisLength;
const isSelected: boolean = selectedAnnotation === roiAnnotation;
// other events
const { uid } = roiAnnotation;
// display text
const displayText = [];
if (area !== undefined) {
displayText.push(formatArea(area));
} else if (length !== undefined) {
displayText.push(
shortAxisLength
? `${formatLength(length, 'μm')} x ${formatLength(
shortAxisLength,
'μm'
)}`
: `${formatLength(length, 'μm')}`
);
}
// convert to measurementItem format compatible with <MeasurementTable /> component
return {
uid,
index,
label,
isActive: isSelected,
displayText,
roiAnnotation,
};
});
const disabled = data.length === 0;
return (
<>
<div
className="overflow-x-hidden overflow-y-auto ohif-scrollbar"
data-cy={'measurements-panel'}
>
<MeasurementTable
title="Measurements"
servicesManager={props.servicesManager}
data={data}
onClick={onMeasurementItemClickHandler}
onEdit={onMeasurementItemEditHandler}
/>
</div>
<div className="flex justify-center p-4">
<ButtonGroup color="black" size="inherit">
{/* Let's hide the save button for now, as export SR for SM is a proof of concept */}
{/*{promptSave && (
<Button
className="px-2 py-2 text-base"
size="initial"
variant={disabled ? 'disabled' : 'outlined'}
color="black"
border="primaryActive"
onClick={promptSave}
>
{props.t('Create Report')}
</Button>
)} */}
{/* <Button
className="px-2 py-2 text-base"
onClick={onDeleteCurrentSRHandler}
>
{props.t('Reject latest report')}
</Button> */}
</ButtonGroup>
</div>
</>
);
}
const connectedMicroscopyPanel = withTranslation(['MicroscopyTable', 'Common'])(
MicroscopyPanel
);
export default connectedMicroscopyPanel;

View File

@ -0,0 +1,87 @@
.DicomMicroscopyViewer .OpenLayersOverlay {
height: 100%;
width: 100%;
display: block !important;
pointer-events: none !important;
}
.DicomMicroscopyViewer .text-primary-light {
font-size: 14px;
color: yellow;
font-weight: normal;
}
.DicomMicroscopyViewer .text-primary-light span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 300px;
/* text-shadow: 0px 1px 1px rgba(225, 225, 225, 0.6),
0px 1px 1px rgba(225, 225, 225, 0.6),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9),
1px 1px 3px rgba(225, 225, 225, 0.9); */
}
.DicomMicroscopyViewer .absolute {
position: absolute;
}
.DicomMicroscopyViewer .flex {
display: flex;
}
.DicomMicroscopyViewer .flex-row {
flex-direction: row;
}
.DicomMicroscopyViewer .flex-col {
flex-direction: column;
}
.DicomMicroscopyViewer .pointer-events-none {
pointer-events: none;
}
.DicomMicroscopyViewer .left-viewport-scrollbar {
left: 0.5rem;
}
.DicomMicroscopyViewer .right-viewport-scrollbar {
right: 1.3rem;
}
.DicomMicroscopyViewer .top-viewport {
top: 0.5rem;
}
.DicomMicroscopyViewer .bottom-viewport {
bottom: 0.5rem;
}
.DicomMicroscopyViewer .bottom-viewport.left-viewport {
bottom: 0.5rem;
left: calc(0.5rem + 250px);
}
.DicomMicroscopyViewer .right-viewport-scrollbar .flex {
justify-content: end;
}
.DicomMicroscopyViewer .microscopy-viewport-overlay {
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.5);
max-width: 40%;
}
.DicomMicroscopyViewer .microscopy-viewport-overlay .flex {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.DicomMicroscopyViewer .top-viewport .flex span:not(.font-light) {
flex-shrink: 0;
}

View File

@ -0,0 +1,155 @@
import React from 'react';
import classnames from 'classnames';
import ConfigPoint from 'config-point';
import listComponentGenerator from './listComponentGenerator';
import './ViewportOverlay.css';
import {
formatDICOMDate,
formatDICOMTime,
formatNumberPrecision,
formatPN,
} from './utils';
interface OverylayItem {
id: string;
title: string;
value?: (props: any) => string;
condition?: (props: any) => boolean;
contents?: (props: any) => { className: string; value: any };
generator?: (props: any) => any;
}
/**
*
* @param {*} config is a configuration object that defines four lists of elements,
* one topLeft, topRight, bottomLeft, bottomRight contents.
* @param {*} extensionManager is used to load the image data.
* @returns
*/
export const generateFromConfig = ({
topLeft,
topRight,
bottomLeft,
bottomRight,
itemGenerator,
}: {
topLeft: OverylayItem[];
topRight: OverylayItem[];
bottomLeft: OverylayItem[];
bottomRight: OverylayItem[];
itemGenerator: (props: any) => any;
}) => {
return (props: any) => {
const topLeftClass = 'top-viewport left-viewport text-primary-light';
const topRightClass =
'top-viewport right-viewport-scrollbar text-primary-light';
const bottomRightClass =
'bottom-viewport right-viewport-scrollbar text-primary-light';
const bottomLeftClass = 'bottom-viewport left-viewport text-primary-light';
const overlay = 'absolute pointer-events-none microscopy-viewport-overlay';
return (
<>
{topLeft && topLeft.length > 0 && (
<div
data-cy={'viewport-overlay-top-left'}
className={classnames(overlay, topLeftClass)}
>
{listComponentGenerator({ ...props, list: topLeft, itemGenerator })}
</div>
)}
{topRight && topRight.length > 0 && (
<div
data-cy={'viewport-overlay-top-right'}
className={classnames(overlay, topRightClass)}
>
{listComponentGenerator({
...props,
list: topRight,
itemGenerator,
})}
</div>
)}
{bottomRight && bottomRight.length > 0 && (
<div
data-cy={'viewport-overlay-bottom-right'}
className={classnames(overlay, bottomRightClass)}
>
{listComponentGenerator({
...props,
list: bottomRight,
itemGenerator,
})}
</div>
)}
{bottomLeft && bottomLeft.length > 0 && (
<div
data-cy={'viewport-overlay-bottom-left'}
className={classnames(overlay, bottomLeftClass)}
>
{listComponentGenerator({
...props,
list: bottomLeft,
itemGenerator,
})}
</div>
)}
</>
);
};
};
const itemGenerator = (props: any) => {
const { item } = props;
const { title, value: valueFunc, condition, contents } = item;
props.image = { ...props.image, ...props.metadata };
props.formatDate = formatDICOMDate;
props.formatTime = formatDICOMTime;
props.formatPN = formatPN;
props.formatNumberPrecision = formatNumberPrecision;
if (condition && !condition(props)) return null;
if (!contents && !valueFunc) return null;
const value = valueFunc && valueFunc(props);
const contentsValue = (contents && contents(props)) || [
{ className: 'mr-1', value: title },
{ classname: 'mr-1 font-light', value },
];
return (
<div key={item.id} className="flex flex-row">
{contentsValue.map((content, idx) => (
<span key={idx} className={content.className}>
{content.value}
</span>
))}
</div>
);
};
const { MicroscopyViewportOverlay } = ConfigPoint.register({
MicroscopyViewportOverlay: {
configBase: {
topLeft: [
// {
// id: 'sm-overlay-patient-name',
// title: 'PatientName',
// condition: ({ instance }) =>
// instance && instance.PatientName && instance.PatientName.Alphabetic,
// value: ({ instance }) =>
// instance.PatientName && instance.PatientName.Alphabetic,
// } as OverylayItem,
],
topRight: [] as OverylayItem[],
bottomLeft: [] as OverylayItem[],
bottomRight: [] as OverylayItem[],
itemGenerator,
generateFromConfig,
},
...(window.config?.MicroscopyViewportOverlay || {}),
},
});
export default MicroscopyViewportOverlay.generateFromConfig(
MicroscopyViewportOverlay
);

View File

@ -0,0 +1,12 @@
const listComponentGenerator = props => {
const { list, itemGenerator } = props;
if (!list) return;
return list.map(item => {
if (!item) return;
const generator = item.generator || itemGenerator;
if (!generator) throw new Error(`No generator for ${item}`);
return generator({ ...props, item });
});
};
export default listComponentGenerator;

View File

@ -0,0 +1,102 @@
import moment from 'moment';
import * as cornerstone from '@cornerstonejs/core';
/**
* Checks if value is valid.
*
* @param {number} value
* @returns {boolean} is valid.
*/
export function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Formats number precision.
*
* @param {number} number
* @param {number} precision
* @returns {number} formatted number.
*/
export function formatNumberPrecision(number, precision) {
if (number !== null) {
return parseFloat(number).toFixed(precision);
}
}
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
*/
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
* @returns {string} formatted name.
*/
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return;
}
// Convert the first ^ to a ', '. String.replace() only affects
// the first appearance of the character.
const commaBetweenFirstAndLast = name.replace('^', ', ');
// Replace any remaining '^' characters with spaces
const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' ');
// Trim any extraneous whitespace
return cleaned.trim();
}
/**
* Gets compression type
*
* @param {number} imageId
* @returns {string} comrpession type.
*/
export function getCompression(imageId) {
const generalImageModule =
cornerstone.metaData.get('generalImageModule', imageId) || {};
const {
lossyImageCompression,
lossyImageCompressionRatio,
lossyImageCompressionMethod,
} = generalImageModule;
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = formatNumberPrecision(
lossyImageCompressionRatio,
2
);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
}

View File

@ -0,0 +1,198 @@
import { ServicesManager, CommandsManager, ExtensionManager } from '@ohif/core';
import styles from './utils/styles';
import callInputDialog from './utils/callInputDialog';
export default function getCommandsModule({
servicesManager,
commandsManager,
extensionManager,
}: {
servicesManager: ServicesManager;
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
}) {
const {
viewportGridService,
uiDialogService,
microscopyService,
} = servicesManager.services;
const actions = {
// Measurement tool commands:
deleteMeasurement: ({ uid }) => {
if (uid) {
const roiAnnotation = microscopyService.getAnnotation(uid);
if (roiAnnotation) microscopyService.removeAnnotation(roiAnnotation);
}
},
setLabel: ({ uid }) => {
const roiAnnotation = microscopyService.getAnnotation(uid);
callInputDialog({
uiDialogService,
title: 'Enter your annotation',
defaultValue: '',
callback: (value: string, action: string) => {
switch (action) {
case 'save': {
roiAnnotation.setLabel(value);
microscopyService.triggerRelabel(roiAnnotation);
}
}
},
});
},
setToolActive: ({ toolName, toolGroupId = 'MICROSCOPY' }) => {
const dragPanOnMiddle = [
'dragPan',
{
bindings: {
mouseButtons: ['middle'],
},
},
];
const dragZoomOnRight = [
'dragZoom',
{
bindings: {
mouseButtons: ['right'],
},
},
];
if (
[
'line',
'box',
'circle',
'point',
'polygon',
'freehandpolygon',
'freehandline',
].indexOf(toolName) >= 0
) {
// TODO: read from configuration
let options = {
geometryType: toolName,
vertexEnabled: true,
styleOptions: styles.default,
bindings: {
mouseButtons: ['left'],
},
} as any;
if ('line' === toolName) {
options.minPoints = 2;
options.maxPoints = 2;
} else if ('point' === toolName) {
delete options.styleOptions;
delete options.vertexEnabled;
}
microscopyService.activateInteractions([
['draw', options],
dragPanOnMiddle,
dragZoomOnRight,
]);
} else if (toolName == 'dragPan') {
microscopyService.activateInteractions([
[
'dragPan',
{
bindings: {
mouseButtons: ['left', 'middle'],
},
},
],
dragZoomOnRight,
]);
} else {
microscopyService.activateInteractions([
[
toolName,
{
bindings: {
mouseButtons: ['left'],
},
},
],
dragPanOnMiddle,
dragZoomOnRight,
]);
}
},
incrementActiveViewport: () => {
const { activeViewportIndex, viewports } = viewportGridService.getState();
const nextViewportIndex = (activeViewportIndex + 1) % viewports.length;
viewportGridService.setActiveViewportIndex(nextViewportIndex);
},
decrementActiveViewport: () => {
const { activeViewportIndex, viewports } = viewportGridService.getState();
const nextViewportIndex =
(activeViewportIndex - 1 + viewports.length) % viewports.length;
viewportGridService.setActiveViewportIndex(nextViewportIndex);
},
toggleOverlays: () => {
// overlay
const overlays = document.getElementsByClassName(
'microscopy-viewport-overlay'
);
let onoff = false; // true if this will toggle on
for (let i = 0; i < overlays.length; i++) {
if (i === 0) onoff = overlays.item(0).classList.contains('hidden');
overlays.item(i).classList.toggle('hidden');
}
// overview
const { activeViewportIndex, viewports } = viewportGridService.getState();
microscopyService.toggleOverviewMap(activeViewportIndex);
},
toggleAnnotations: () => {
microscopyService.toggleROIsVisibility();
},
};
const definitions = {
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
storeContexts: [] as any[],
options: {},
},
setLabel: {
commandFn: actions.setLabel,
storeContexts: [] as any[],
options: {},
},
setToolActive: {
commandFn: actions.setToolActive,
storeContexts: [] as any[],
options: {},
},
incrementActiveViewport: {
commandFn: actions.incrementActiveViewport,
storeContexts: [] as any[],
},
decrementActiveViewport: {
commandFn: actions.decrementActiveViewport,
storeContexts: [] as any[],
},
toggleOverlays: {
commandFn: actions.toggleOverlays,
storeContexts: [] as any[],
options: {},
},
toggleAnnotations: {
commandFn: actions.toggleAnnotations,
storeContexts: [] as any[],
options: {},
},
};
return {
actions,
definitions,
defaultContext: 'MICROSCOPY',
};
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { ServicesManager, CommandsManager, ExtensionManager } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui';
import MicroscopyPanel from './components/MicroscopyPanel/MicroscopyPanel';
// TODO:
// - No loading UI exists yet
// - cancel promises when component is destroyed
// - show errors in UI for thumbnails if promise fails
export default function getPanelModule({
commandsManager,
extensionManager,
servicesManager,
}: {
servicesManager: ServicesManager;
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
}) {
const wrappedMeasurementPanel = () => {
const [
{ activeViewportIndex, viewports },
viewportGridService,
] = useViewportGrid();
return (
<MicroscopyPanel
viewports={viewports}
activeViewportIndex={activeViewportIndex}
onSaveComplete={() => {}}
onRejectComplete={() => {}}
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
/>
);
};
return [
{
name: 'measure',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurements',
secondaryLabel: 'Measurements',
component: wrappedMeasurementPanel,
},
];
}

View File

@ -0,0 +1,11 @@
import moment from 'moment';
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
*/
export default function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}

View File

@ -0,0 +1,9 @@
import formatDICOMDate from './formatDICOMDate';
describe('formatDICOMDate', () => {
it('should format DICOM date string', () => {
const date = '20180916';
const formattedDate = formatDICOMDate(date);
expect(formattedDate).toEqual('Sep 16, 2018');
});
});

View File

@ -0,0 +1,21 @@
/**
* Formats a patient name for display purposes.
*
* @param {string} name DICOM patient name string
* @returns {string} formatted name
*/
export default function formatDICOMPatientName(name) {
if (typeof name !== 'string') return;
/**
* Convert the first ^ to a ', '. String.replace() only affects
* the first appearance of the character.
*/
const commaBetweenFirstAndLast = name.replace('^', ', ');
/** Replace any remaining '^' characters with spaces */
const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' ');
/** Trim any extraneous whitespace */
return cleaned.trim();
}

View File

@ -0,0 +1,17 @@
import formatDICOMPatientName from './formatDICOMPatientName';
describe('formatDICOMPatientName', () => {
it('should format DICOM patient name correctly', () => {
const patientName = 'Blackford^Test';
const formattedPatientName = formatDICOMPatientName(patientName);
expect(formattedPatientName).toEqual('Blackford, Test');
});
it('should return undefined it input is not a string', () => {
expect(formatDICOMPatientName(123)).toEqual(undefined);
expect(formatDICOMPatientName(null)).toEqual(undefined);
expect(formatDICOMPatientName(undefined)).toEqual(undefined);
expect(formatDICOMPatientName(false)).toEqual(undefined);
expect(formatDICOMPatientName([])).toEqual(undefined);
});
});

View File

@ -0,0 +1,17 @@
import moment from 'moment';
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
*/
export default function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}

View File

@ -0,0 +1,9 @@
import formatDICOMTime from './formatDICOMTime';
describe('formatDICOMTime', () => {
it('should format DICOM time string', () => {
const time = '101300.000';
const formattedTime = formatDICOMTime(time);
expect(formattedTime).toEqual('10:13:00');
});
});

View File

@ -0,0 +1,9 @@
/**
* Formats a number to a fixed precision.
*
* @param {number} number
* @param {number} precision
*/
export default function formatNumberPrecision(number, precision) {
return Number(parseFloat(number).toFixed(precision));
}

View File

@ -0,0 +1,9 @@
import formatNumberPrecision from './formatNumberPrecision';
describe('formatNumberPrecision', () => {
it('should format number precision', () => {
const number = 0.229387;
const formattedNumber = formatNumberPrecision(number, 2);
expect(formattedNumber).toEqual(0.23);
});
});

View File

@ -0,0 +1,15 @@
import formatDICOMPatientName from './formatDICOMPatientName';
import formatDICOMDate from './formatDICOMDate';
import formatDICOMTime from './formatDICOMTime';
import formatNumberPrecision from './formatNumberPrecision';
import isValidNumber from './isValidNumber';
const helpers = {
formatDICOMPatientName,
formatDICOMDate,
formatDICOMTime,
formatNumberPrecision,
isValidNumber,
};
export default helpers;

View File

@ -0,0 +1,3 @@
export default function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}

View File

@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View File

@ -0,0 +1,117 @@
import { id } from './id';
import React, { Suspense } from 'react';
import getPanelModule from './getPanelModule';
import getCommandsModule from './getCommandsModule';
import { useViewportGrid } from '@ohif/ui';
import getDicomMicroscopySopClassHandler from './DicomMicroscopySopClassHandler';
import getDicomMicroscopySRSopClassHandler from './DicomMicroscopySRSopClassHandler';
import MicroscopyService from './services/MicroscopyService';
const Component = React.lazy(() => {
return import('./DicomMicroscopyViewport');
});
const MicroscopyViewport = props => {
return (
<Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</Suspense>
);
};
/**
* You can remove any of the following modules if you don't need them.
*/
export default {
/**
* Only required property. Should be a unique value across all extensions.
* You ID can be anything you want, but it should be unique.
*/
id,
async preRegistration({
servicesManager,
commandsManager,
configuration = {},
appConfig,
}) {
servicesManager.registerService(
MicroscopyService.REGISTRATION(servicesManager)
);
},
/**
* ViewportModule should provide a list of viewports that will be available in OHIF
* for Modes to consume and use in the viewports. Each viewport is defined by
* {name, component} object. Example of a viewport module is the CornerstoneViewport
* that is provided by the Cornerstone extension in OHIF.
*/
getViewportModule({ servicesManager, extensionManager, commandsManager }) {
/**
*
* @param props {*}
* @param props.displaySets
* @param props.viewportIndex
* @param props.viewportLabel
* @param props.dataSource
* @param props.viewportOptions
* @param props.displaySetOptions
* @returns
*/
const ExtendedMicroscopyViewport = props => {
const { viewportOptions } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const { viewports, activeViewportIndex } = viewportGrid;
return (
<MicroscopyViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
activeViewportIndex={activeViewportIndex}
setViewportActive={(viewportIndex: number) => {
viewportGridService.setActiveViewportIndex(viewportIndex);
}}
viewportData={viewportOptions}
{...props}
/>
);
};
return [
{
name: 'microscopy-dicom',
component: ExtendedMicroscopyViewport,
},
];
},
/**
* SopClassHandlerModule should provide a list of sop class handlers that will be
* available in OHIF for Modes to consume and use to create displaySets from Series.
* Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}.
* Examples include the default sop class handler provided by the default extension
*/
getSopClassHandlerModule({
servicesManager,
commandsManager,
extensionManager,
}) {
return [
getDicomMicroscopySopClassHandler({
servicesManager,
extensionManager,
}),
getDicomMicroscopySRSopClassHandler({
servicesManager,
extensionManager,
}),
];
},
getPanelModule,
getCommandsModule,
};

View File

@ -0,0 +1,634 @@
import ViewerManager, { EVENTS as ViewerEvents } from '../tools/viewerManager';
import RoiAnnotation, {
EVENTS as AnnotationEvents,
} from '../utils/RoiAnnotation';
import styles from '../utils/styles';
import { DicomMetadataStore, PubSubService } from '@ohif/core';
const EVENTS = {
ANNOTATION_UPDATED: 'annotationUpdated',
ANNOTATION_SELECTED: 'annotationSelected',
ANNOTATION_REMOVED: 'annotationRemoved',
RELABEL: 'relabel',
DELETE: 'delete',
};
/**
* MicroscopyService is responsible to manage multiple third-party API's
* microscopy viewers expose methods to manage the interaction with these
* viewers and handle their ROI graphics to create, remove and modify the
* ROI annotations relevant to the application
*/
export default class MicroscopyService extends PubSubService {
public static REGISTRATION = serviceManager => {
return {
name: 'microscopyService',
altName: 'MicroscopyService',
create: ({ configuration = {} }) => {
return new MicroscopyService(serviceManager);
},
};
};
serviceManager: any;
managedViewers = new Set();
roiUids = new Set();
annotations = {};
selectedAnnotation = null;
pendingFocus = false;
constructor(serviceManager) {
super(EVENTS);
this.serviceManager = serviceManager;
this._onRoiAdded = this._onRoiAdded.bind(this);
this._onRoiModified = this._onRoiModified.bind(this);
this._onRoiRemoved = this._onRoiRemoved.bind(this);
this._onRoiUpdated = this._onRoiUpdated.bind(this);
this._onRoiSelected = this._onRoiSelected.bind(this);
this.isROIsVisible = true;
}
/**
* Cleares all the annotations and managed viewers, setting the manager state
* to its initial state
*/
clear() {
this.managedViewers.forEach(managedViewer => managedViewer.destroy());
this.managedViewers.clear();
for (var key in this.annotations) {
delete this.annotations[key];
}
this.roiUids.clear();
this.selectedAnnotation = null;
this.pendingFocus = false;
}
clearAnnotations() {
Object.keys(this.annotations).forEach(uid => {
this.removeAnnotation(this.annotations[uid]);
});
}
/**
* Observes when a ROI graphic is added, creating the correspondent annotation
* with the current graphic and view state.
* Creates a subscription for label updating for the created annotation and
* publishes an ANNOTATION_UPDATED event when it happens.
* Also triggers the relabel process after the graphic is placed.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiAdded(data) {
const { roiGraphic, managedViewer, label } = data;
const { studyInstanceUID, seriesInstanceUID } = managedViewer;
const viewState = managedViewer.getViewState();
const roiAnnotation = new RoiAnnotation(
roiGraphic,
studyInstanceUID,
seriesInstanceUID,
'',
viewState
);
this.roiUids.add(roiGraphic.uid);
this.annotations[roiGraphic.uid] = roiAnnotation;
roiAnnotation.subscribe(AnnotationEvents.LABEL_UPDATED, () => {
this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, roiAnnotation);
});
if (label !== undefined) {
roiAnnotation.setLabel(label);
} else {
const onRelabel = item =>
managedViewer.updateROIProperties({
uid: roiGraphic.uid,
properties: { label: item.label, finding: item.finding },
});
this.triggerRelabel(roiAnnotation, true, onRelabel);
}
}
/**
* Observes when a ROI graphic is modified, updating the correspondent
* annotation with the current graphic and view state.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The modified ROI graphic object
*/
_onRoiModified(data) {
const { roiGraphic, managedViewer } = data;
const roiAnnotation = this.getAnnotation(roiGraphic.uid);
if (!roiAnnotation) return;
roiAnnotation.setRoiGraphic(roiGraphic);
roiAnnotation.setViewState(managedViewer.getViewState());
}
/**
* Observes when a ROI graphic is removed, reflecting the removal in the
* annotations' state.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The removed ROI graphic object
*/
_onRoiRemoved(data) {
const { roiGraphic } = data;
this.roiUids.delete(roiGraphic.uid);
this.annotations[roiGraphic.uid].destroy();
delete this.annotations[roiGraphic.uid];
this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiGraphic);
}
/**
* Observes any changes on ROI graphics and synchronize all the managed
* viewers to reflect those changes.
* Also publishes an ANNOTATION_UPDATED event to notify the subscribers.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiUpdated(data) {
const { roiGraphic, managedViewer } = data;
this.synchronizeViewers(managedViewer);
this._broadcastEvent(EVENTS.ANNOTATION_UPDATED, this.getAnnotation(roiGraphic.uid));
}
/**
* Observes when an ROI is selected.
* Also publishes an ANNOTATION_SELECTED event to notify the subscribers.
*
* @param {Object} data The published data
* @param {Object} data.roiGraphic The added ROI graphic object
* @param {ViewerManager} data.managedViewer The origin viewer for the event
*/
_onRoiSelected(data) {
const { roiGraphic } = data;
const selectedAnnotation = this.getAnnotation(roiGraphic.uid);
if (selectedAnnotation && selectedAnnotation !== this.getSelectedAnnotation()) {
if (this.selectedAnnotation) this.clearSelection();
this.selectedAnnotation = selectedAnnotation;
this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, selectedAnnotation);
}
}
/**
* Creates the subscriptions for the managed viewer being added
*
* @param {ViewerManager} managedViewer The viewer being added
*/
_addManagedViewerSubscriptions(managedViewer) {
managedViewer._roiAddedSubscription = managedViewer.subscribe(ViewerEvents.ADDED, this._onRoiAdded);
managedViewer._roiModifiedSubscription = managedViewer.subscribe(ViewerEvents.MODIFIED, this._onRoiModified);
managedViewer._roiRemovedSubscription = managedViewer.subscribe(ViewerEvents.REMOVED, this._onRoiRemoved);
managedViewer._roiUpdatedSubscription = managedViewer.subscribe(ViewerEvents.UPDATED, this._onRoiUpdated);
managedViewer._roiSelectedSubscription = managedViewer.subscribe(ViewerEvents.UPDATED, this._onRoiSelected);
}
/**
* Removes the subscriptions for the managed viewer being removed
*
* @param {ViewerManager} managedViewer The viewer being removed
*/
_removeManagedViewerSubscriptions(managedViewer) {
managedViewer._roiAddedSubscription && managedViewer._roiAddedSubscription.unsubscribe();
managedViewer._roiModifiedSubscription && managedViewer._roiModifiedSubscription.unsubscribe();
managedViewer._roiRemovedSubscription && managedViewer._roiRemovedSubscription.unsubscribe();
managedViewer._roiUpdatedSubscription && managedViewer._roiUpdatedSubscription.unsubscribe();
managedViewer._roiSelectedSubscription && managedViewer._roiSelectedSubscription.unsubscribe();
managedViewer._roiAddedSubscription = null;
managedViewer._roiModifiedSubscription = null;
managedViewer._roiRemovedSubscription = null;
managedViewer._roiUpdatedSubscription = null;
managedViewer._roiSelectedSubscription = null;
}
/**
* Returns the managed viewers that are displaying the image with the given
* study and series UIDs
*
* @param {String} studyInstanceUID UID for the study
* @param {String} seriesInstanceUID UID for the series
*
* @returns {Array} The managed viewers for the given series UID
*/
_getManagedViewersForSeries(studyInstanceUID, seriesInstanceUID) {
const filter = managedViewer =>
managedViewer.studyInstanceUID === studyInstanceUID &&
managedViewer.seriesInstanceUID === seriesInstanceUID;
return Array.from(this.managedViewers).filter(filter);
}
/**
* Returns the managed viewers that are displaying the image with the given
* study UID
*
* @param {String} studyInstanceUID UID for the study
*
* @returns {Array} The managed viewers for the given series UID
*/
getManagedViewersForStudy(studyInstanceUID) {
const filter = managedViewer =>
managedViewer.studyInstanceUID === studyInstanceUID;
return Array.from(this.managedViewers).filter(filter);
}
/**
* Restores the created annotations for the viewer being added
*
* @param {ViewerManager} managedViewer The viewer being added
*/
_restoreAnnotations(managedViewer) {
const { studyInstanceUID, seriesInstanceUID } = managedViewer;
const annotations = this.getAnnotationsForSeries(
studyInstanceUID,
seriesInstanceUID
);
annotations.forEach(roiAnnotation => {
managedViewer.addRoiGraphic(roiAnnotation.roiGraphic);
});
}
/**
* Creates a managed viewer instance for the given thrid-party API's viewer.
* Restores existing annotations for the given study/series.
* Adds event subscriptions for the viewer being added.
* Focuses the selected annotation when the viewer is being loaded into the
* active viewport.
*
* @param {Object} viewer Third-party viewer API's object to be managed
* @param {Number} viewportIndex The index of the viewport to load the viewer
* @param {HTMLElement} container The DOM element where it will be renderd
* @param {String} studyInstanceUID The study UID of the loaded image
* @param {String} seriesInstanceUID The series UID of the loaded image
* @param {Array} displaySets All displaySets related to the same StudyInstanceUID
*
* @returns {ViewerManager} managed viewer
*/
addViewer(
viewer,
viewportIndex,
container,
studyInstanceUID,
seriesInstanceUID
) {
const managedViewer = new ViewerManager(
viewer,
viewportIndex,
container,
studyInstanceUID,
seriesInstanceUID
);
this._restoreAnnotations(managedViewer);
viewer._manager = managedViewer;
this.managedViewers.add(managedViewer);
// this._potentiallyLoadSR(studyInstanceUID, displaySets);
this._addManagedViewerSubscriptions(managedViewer);
if (this.pendingFocus) {
this.pendingFocus = false;
this.focusAnnotation(this.selectedAnnotation, viewportIndex);
}
return managedViewer;
}
_potentiallyLoadSR(StudyInstanceUID, displaySets) {
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
const { FrameOfReferenceUID, othersFrameOfReferenceUID } = smDisplaySet;
if (!studyMetadata) {
return;
}
let derivedDisplaySets = FrameOfReferenceUID
? displaySets.filter(
ds =>
ds.ReferencedFrameOfReferenceUID === FrameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
othersFrameOfReferenceUID.includes(ds.ReferencedFrameOfReferenceUID)
)
: [];
if (!derivedDisplaySets.length) {
return;
}
derivedDisplaySets = derivedDisplaySets.filter(ds => ds.Modality === 'SR');
if (derivedDisplaySets.some(ds => ds.isLoaded === true)) {
// Don't auto load
return;
}
// find most recent and load it.
let recentDateTime = 0;
let recentDisplaySet = derivedDisplaySets[0];
derivedDisplaySets.forEach(ds => {
const dateTime = Number(`${ds.SeriesDate}${ds.SeriesTime}`);
if (dateTime > recentDateTime) {
recentDateTime = dateTime;
recentDisplaySet = ds;
}
});
recentDisplaySet.isLoading = true;
recentDisplaySet.load(smDisplaySet);
}
/**
* Removes the given third-party viewer API's object from the managed viewers
* and cleares all its event subscriptions
*
* @param {Object} viewer Third-party viewer API's object to be removed
*/
removeViewer(viewer) {
const managedViewer = viewer._manager;
this._removeManagedViewerSubscriptions(managedViewer);
managedViewer.destroy();
this.managedViewers.delete(managedViewer);
}
/**
* Toggle ROIs visibility
*/
toggleROIsVisibility() {
this.isROIsVisible ? this.hideROIs() : this.showROIs;
this.isROIsVisible = !this.isROIsVisible;
}
/**
* Hide all ROIs
*/
hideROIs() {
this.managedViewers.forEach(mv => mv.hideROIs());
}
/** Show all ROIs */
showROIs() {
this.managedViewers.forEach(mv => mv.showROIs());
}
/**
* Returns a RoiAnnotation instance for the given ROI UID
*
* @param {String} uid UID of the annotation
*
* @returns {RoiAnnotation} The RoiAnnotation instance found for the given UID
*/
getAnnotation(uid) {
return this.annotations[uid];
}
/**
* Returns all the RoiAnnotation instances being managed
*
* @returns {Array} All RoiAnnotation instances
*/
getAnnotations() {
const annotations = [];
Object.keys(this.annotations).forEach(uid => {
annotations.push(this.getAnnotation(uid));
});
return annotations;
}
/**
* Returns the RoiAnnotation instances registered with the given study UID
*
* @param {String} studyInstanceUID UID for the study
*/
getAnnotationsForStudy(studyInstanceUID) {
const filter = a => a.studyInstanceUID === studyInstanceUID;
return this.getAnnotations().filter(filter);
}
/**
* Returns the RoiAnnotation instances registered with the given study and
* series UIDs
*
* @param {String} studyInstanceUID UID for the study
* @param {String} seriesInstanceUID UID for the series
*/
getAnnotationsForSeries(studyInstanceUID, seriesInstanceUID) {
const filter = annotation =>
annotation.studyInstanceUID === studyInstanceUID &&
annotation.seriesInstanceUID === seriesInstanceUID;
return this.getAnnotations().filter(filter);
}
/**
* Returns the selected RoiAnnotation instance or null if none is selected
*
* @returns {RoiAnnotation} The selected RoiAnnotation instance
*/
getSelectedAnnotation() {
return this.selectedAnnotation;
}
/**
* Clear current RoiAnnotation selection
*/
clearSelection() {
if (this.selectedAnnotation) {
this.setROIStyle(this.selectedAnnotation.uid, {
stroke: {
color: '#00ff00',
},
});
}
this.selectedAnnotation = null;
}
/**
* Selects the given RoiAnnotation instance, publishing an ANNOTATION_SELECTED
* event to notify all the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be selected
*/
selectAnnotation(roiAnnotation) {
if (this.selectedAnnotation) this.clearSelection();
this.selectedAnnotation = roiAnnotation;
this._broadcastEvent(EVENTS.ANNOTATION_SELECTED, roiAnnotation);
this.setROIStyle(roiAnnotation.uid, styles.active);
}
/**
* Toggles overview map
*
* @param viewportIndex The active viewport index
* @returns {void}
*/
toggleOverviewMap(viewportIndex) {
const managedViewers = Array.from(this.managedViewers);
const managedViewer = managedViewers.find(
mv => mv.viewportIndex === viewportIndex
);
if (managedViewer) {
managedViewer.toggleOverviewMap();
}
}
/**
* Removes a RoiAnnotation instance from the managed annotations and reflects
* its removal on all third-party viewers being managed
*
* @param {RoiAnnotation} roiAnnotation The instance to be removed
*/
removeAnnotation(roiAnnotation) {
const { uid, studyInstanceUID, seriesInstanceUID } = roiAnnotation;
const filter = managedViewer =>
managedViewer.studyInstanceUID === studyInstanceUID &&
managedViewer.seriesInstanceUID === seriesInstanceUID;
const managedViewers = Array.from(this.managedViewers).filter(filter);
managedViewers.forEach(managedViewer =>
managedViewer.removeRoiGraphic(uid)
);
if (this.annotations[uid]) {
this.roiUids.delete(uid);
this.annotations[uid].destroy();
delete this.annotations[uid];
this._broadcastEvent(EVENTS.ANNOTATION_REMOVED, roiAnnotation);
}
}
/**
* Focus the given RoiAnnotation instance by changing the OpenLayers' Map view
* state of the managed viewer with the given viewport index.
* If the image for the given annotation is not yet loaded into the viewport,
* it will set a pendingFocus flag to true in order to perform the focus when
* the managed viewer instance is created.
*
* @param {RoiAnnotation} roiAnnotation RoiAnnotation instance to be focused
* @param {Number} viewportIndex Index of the viewport to focus
*/
focusAnnotation(roiAnnotation, viewportIndex) {
const filter = mv => mv.viewportIndex === viewportIndex;
const managedViewer = Array.from(this.managedViewers).find(filter);
if (managedViewer) {
managedViewer.setViewStateByExtent(roiAnnotation);
} else {
this.pendingFocus = true;
}
}
/**
* Synchronize the ROI graphics for all the managed viewers that has the same
* series UID of the given managed viewer
*
* @param {ViewerManager} baseManagedViewer Reference managed viewer
*/
synchronizeViewers(baseManagedViewer) {
const { studyInstanceUID, seriesInstanceUID } = baseManagedViewer;
const managedViewers = this._getManagedViewersForSeries(
studyInstanceUID,
seriesInstanceUID
);
// Prevent infinite loops arrising from updates.
managedViewers.forEach(managedViewer =>
this._removeManagedViewerSubscriptions(managedViewer)
);
managedViewers.forEach(managedViewer => {
if (managedViewer === baseManagedViewer) {
return;
}
const annotations = this.getAnnotationsForSeries(
studyInstanceUID,
seriesInstanceUID
);
managedViewer.clearRoiGraphics();
annotations.forEach(roiAnnotation => {
managedViewer.addRoiGraphic(roiAnnotation.roiGraphic);
});
});
managedViewers.forEach(managedViewer =>
this._addManagedViewerSubscriptions(managedViewer)
);
}
/**
* Activates interactions across all the viewers being managed
*
* @param {Array} interactions interactions
*/
activateInteractions(interactions) {
this.managedViewers.forEach(mv => mv.activateInteractions(interactions));
this.activeInteractions = interactions;
}
/**
* Triggers the relabelling process for the given RoiAnnotation instance, by
* publishing the RELABEL event to notify the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be relabelled
* @param {boolean} newAnnotation Whether the annotation is newly drawn (so it deletes on cancel).
*/
triggerRelabel(roiAnnotation, newAnnotation = false, onRelabel) {
if (!onRelabel) {
onRelabel = ({ label }) =>
this.managedViewers.forEach(mv =>
mv.updateROIProperties({
uid: roiAnnotation.uid,
properties: { label },
})
);
}
this._broadcastEvent(EVENTS.RELABEL, {
roiAnnotation,
deleteCallback: () => this.removeAnnotation(roiAnnotation),
successCallback: onRelabel,
newAnnotation,
});
}
/**
* Triggers the deletion process for the given RoiAnnotation instance, by
* publishing the DELETE event to notify the subscribers
*
* @param {RoiAnnotation} roiAnnotation The instance to be deleted
*/
triggerDelete(roiAnnotation) {
this._broadcastEvent(EVENTS.DELETE, roiAnnotation);
}
/**
* Set ROI style for all managed viewers
*
* @param {string} uid The ROI uid that will be styled
* @param {object} styleOptions - Style options
* @param {object*} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {object*} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {object*} styleOptions.image - Style options for image
*/
setROIStyle(uid, styleOptions) {
this.managedViewers.forEach(mv => mv.setROIStyle(uid, styleOptions));
}
}
export { EVENTS };

View File

@ -0,0 +1,491 @@
import coordinateFormatScoord3d2Geometry from '../utils/coordinateFormatScoord3d2Geometry';
import styles from '../utils/styles';
import { PubSubService } from '@ohif/core';
// Events from the third-party viewer
const ApiEvents = {
/** Triggered when a ROI was added. */
ROI_ADDED: 'dicommicroscopyviewer_roi_added',
/** Triggered when a ROI was modified. */
ROI_MODIFIED: 'dicommicroscopyviewer_roi_modified',
/** Triggered when a ROI was removed. */
ROI_REMOVED: 'dicommicroscopyviewer_roi_removed',
/** Triggered when a ROI was drawn. */
ROI_DRAWN: `dicommicroscopyviewer_roi_drawn`,
/** Triggered when a ROI was selected. */
ROI_SELECTED: `dicommicroscopyviewer_roi_selected`,
/** Triggered when a viewport move has started. */
MOVE_STARTED: `dicommicroscopyviewer_move_started`,
/** Triggered when a viewport move has ended. */
MOVE_ENDED: `dicommicroscopyviewer_move_ended`,
/** Triggered when a loading of data has started. */
LOADING_STARTED: `dicommicroscopyviewer_loading_started`,
/** Triggered when a loading of data has ended. */
LOADING_ENDED: `dicommicroscopyviewer_loading_ended`,
/** Triggered when an error occurs during loading of data. */
LOADING_ERROR: `dicommicroscopyviewer_loading_error`,
/* Triggered when the loading of an image tile has started. */
FRAME_LOADING_STARTED: `dicommicroscopyviewer_frame_loading_started`,
/* Triggered when the loading of an image tile has ended. */
FRAME_LOADING_ENDED: `dicommicroscopyviewer_frame_loading_ended`,
/* Triggered when the error occurs during loading of an image tile. */
FRAME_LOADING_ERROR: `dicommicroscopyviewer_frame_loading_ended`,
};
const EVENTS = {
ADDED: 'added',
MODIFIED: 'modified',
REMOVED: 'removed',
UPDATED: 'updated',
SELECTED: 'selected',
};
/**
* ViewerManager encapsulates the complexity of the third-party viewer and
* expose only the features/behaviors that are relevant to the application
*/
class ViewerManager extends PubSubService {
constructor(
viewer,
viewportIndex,
container,
studyInstanceUID,
seriesInstanceUID
) {
super(EVENTS);
this.viewer = viewer;
this.viewportIndex = viewportIndex;
this.container = container;
this.studyInstanceUID = studyInstanceUID;
this.seriesInstanceUID = seriesInstanceUID;
this.onRoiAdded = this.roiAddedHandler.bind(this);
this.onRoiModified = this.roiModifiedHandler.bind(this);
this.onRoiRemoved = this.roiRemovedHandler.bind(this);
this.onRoiSelected = this.roiSelectedHandler.bind(this);
this.contextMenuCallback = () => {};
// init symbols
const symbols = Object.getOwnPropertySymbols(this.viewer);
this._drawingSource = symbols.find(p => p.description === 'drawingSource');
this._pyramid = symbols.find(p => p.description === 'pyramid');
this._map = symbols.find(p => p.description === 'map');
this._affine = symbols.find(p => p.description === 'affine');
this.registerEvents();
this.activateDefaultInteractions();
}
addContextMenuCallback(callback) {
this.contextMenuCallback = callback;
}
/**
* Destroys this managed viewer instance, clearing all the event handlers
*/
destroy() {
this.unregisterEvents();
}
/**
* This is to overrides the _broadcastEvent method of PubSubService and always
* send the ROI graphic object and this managed viewer instance.
* Due to the way that PubSubService is written, the same name override of the
* function doesn't work.
*
* @param {String} key key Subscription key
* @param {Object} roiGraphic ROI graphic object created by the third-party API
*/
publish(key, roiGraphic) {
this._broadcastEvent(key, {
roiGraphic,
managedViewer: this,
});
}
/**
* Registers all the relevant event handlers for the third-party API
*/
registerEvents() {
this.container.addEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded);
this.container.addEventListener(ApiEvents.ROI_MODIFIED, this.onRoiModified);
this.container.addEventListener(ApiEvents.ROI_REMOVED, this.onRoiRemoved);
this.container.addEventListener(ApiEvents.ROI_SELECTED, this.onRoiSelected);
}
/**
* Cleares all the relevant event handlers for the third-party API
*/
unregisterEvents() {
this.container.removeEventListener(ApiEvents.ROI_ADDED, this.onRoiAdded);
this.container.removeEventListener(
ApiEvents.ROI_MODIFIED,
this.onRoiModified
);
this.container.removeEventListener(
ApiEvents.ROI_REMOVED,
this.onRoiRemoved
);
this.container.removeEventListener(
ApiEvents.ROI_SELECTED,
this.onRoiSelected
);
}
/**
* Handles the ROI_ADDED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiAddedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.ADDED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_MODIFIED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiModifiedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.MODIFIED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_REMOVED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiRemovedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.REMOVED, roiGraphic);
this.publish(EVENTS.UPDATED, roiGraphic);
}
/**
* Handles the ROI_SELECTED event triggered by the third-party API
*
* @param {Event} event Event triggered by the third-party API
*/
roiSelectedHandler(event) {
const roiGraphic = event.detail.payload;
this.publish(EVENTS.SELECTED, roiGraphic);
}
/**
* Run the given callback operation without triggering any events for this
* instance, so subscribers will not be affected
*
* @param {Function} callback Callback that will run sinlently
*/
runSilently(callback) {
this.unregisterEvents();
callback();
this.registerEvents();
}
/**
* Removes all the ROI graphics from the third-party API
*/
clearRoiGraphics() {
this.runSilently(() => this.viewer.removeAllROIs());
}
showROIs() {
this.viewer.showROIs();
}
hideROIs() {
this.viewer.hideROIs();
}
/**
* Adds the given ROI graphic into the third-party API
*
* @param {Object} roiGraphic ROI graphic object to be added
*/
addRoiGraphic(roiGraphic) {
this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default));
}
/**
* Adds the given ROI graphic into the third-party API, and also add a label.
* Used for importing from SR.
*
* @param {Object} roiGraphic ROI graphic object to be added.
* @param {String} label The label of the annotation.
*/
addRoiGraphicWithLabel(roiGraphic, label) {
// NOTE: Dicom Microscopy Viewer will override styles for "Text" evalutations
// to hide all other geometries, we are not going to use its label.
// if (label) {
// if (!roiGraphic.properties) roiGraphic.properties = {};
// roiGraphic.properties.label = label;
// }
this.runSilently(() => this.viewer.addROI(roiGraphic, styles.default));
this._broadcastEvent(EVENTS.ADDED, {
roiGraphic,
managedViewer: this,
label,
});
}
/**
* Sets ROI style
*
* @param {String} uid ROI graphic UID to be styled
* @param {object} styleOptions - Style options
* @param {object} styleOptions.stroke - Style options for the outline of the geometry
* @param {number[]} styleOptions.stroke.color - RGBA color of the outline
* @param {number} styleOptions.stroke.width - Width of the outline
* @param {object} styleOptions.fill - Style options for body the geometry
* @param {number[]} styleOptions.fill.color - RGBA color of the body
* @param {object} styleOptions.image - Style options for image
*/
setROIStyle(uid, styleOptions) {
this.viewer.setROIStyle(uid, styleOptions);
}
/**
* Removes the ROI graphic with the given UID from the third-party API
*
* @param {String} uid ROI graphic UID to be removed
*/
removeRoiGraphic(uid) {
this.viewer.removeROI(uid);
}
/**
* Update properties of regions of interest.
*
* @param {object} roi - ROI to be updated
* @param {string} roi.uid - Unique identifier of the region of interest
* @param {object} roi.properties - ROI properties
* @returns {void}
*/
updateROIProperties({ uid, properties }) {
this.viewer.updateROI({ uid, properties });
}
/**
* Toggles overview map
*
* @returns {void}
*/
toggleOverviewMap() {
this.viewer.toggleOverviewMap();
}
/**
* Activates the viewer default interactions
* @returns {void}
*/
activateDefaultInteractions() {
/** Disable browser's native context menu inside the canvas */
document.querySelector('.DicomMicroscopyViewer').addEventListener(
'contextmenu',
event => {
event.preventDefault();
// comment out when context menu for microscopy is enabled
// if (typeof this.contextMenuCallback === 'function') {
// this.contextMenuCallback(event);
// }
},
false
);
const defaultInteractions = [
[
'dragPan',
{
bindings: {
mouseButtons: ['middle'],
},
},
],
[
'dragZoom',
{
bindings: {
mouseButtons: ['right'],
},
},
],
['modify', {}],
];
this.activateInteractions(defaultInteractions);
}
/**
* Activates interactions
* @param {Array} interactions Interactions to be activated
* @returns {void}
*/
activateInteractions(interactions) {
const interactionsMap = {
draw: activate =>
activate ? 'activateDrawInteraction' : 'deactivateDrawInteraction',
modify: activate =>
activate ? 'activateModifyInteraction' : 'deactivateModifyInteraction',
translate: activate =>
activate
? 'activateTranslateInteraction'
: 'deactivateTranslateInteraction',
snap: activate =>
activate ? 'activateSnapInteraction' : 'deactivateSnapInteraction',
dragPan: activate =>
activate
? 'activateDragPanInteraction'
: 'deactivateDragPanInteraction',
dragZoom: activate =>
activate
? 'activateDragZoomInteraction'
: 'deactivateDragZoomInteraction',
select: activate =>
activate ? 'activateSelectInteraction' : 'deactivateSelectInteraction',
};
const availableInteractionsName = Object.keys(interactionsMap);
availableInteractionsName.forEach(availableInteractionName => {
const interaction = interactions.find(
interaction => interaction[0] === availableInteractionName
);
if (!interaction) {
const deactivateInteractionMethod = interactionsMap[
availableInteractionName
](false);
this.viewer[deactivateInteractionMethod]();
} else {
const [name, config] = interaction;
const activateInteractionMethod = interactionsMap[name](true);
this.viewer[activateInteractionMethod](config);
}
});
}
/**
* Accesses the internals of third-party API and returns the OpenLayers Map
*
* @returns {Object} OpenLayers Map component instance
*/
_getMapView() {
const map = this._getMap();
return map.getView();
}
_getMap() {
const symbols = Object.getOwnPropertySymbols(this.viewer);
const _map = symbols.find(s => String(s) === 'Symbol(map)');
window['map'] = this.viewer[_map];
return this.viewer[_map];
}
/**
* Returns the current state for the OpenLayers View
*
* @returns {Object} Current view state
*/
getViewState() {
const view = this._getMapView();
return {
center: view.getCenter(),
resolution: view.getResolution(),
zoom: view.getZoom(),
};
}
/**
* Sets the current state for the OpenLayers View
*
* @param {Object} viewState View state to be applied
*/
setViewState(viewState) {
const view = this._getMapView();
view.setZoom(viewState.zoom);
view.setResolution(viewState.resolution);
view.setCenter(viewState.center);
}
setViewStateByExtent(roiAnnotation) {
const coordinates = roiAnnotation.getCoordinates();
if (Array.isArray(coordinates[0]) && !coordinates[2]) {
this._jumpToPolyline(coordinates);
} else if (Array.isArray(coordinates[0])) {
this._jumpToPolygonOrEllipse(coordinates);
} else {
this._jumpToPoint(coordinates);
}
}
_jumpToPoint(coord) {
const pyramid = this.viewer[this._pyramid].metadata;
const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const view = this._getMapView();
view.setCenter(mappedCoord);
}
_jumpToPolyline(coord) {
const pyramid = this.viewer[this._pyramid].metadata;
const mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const view = this._getMapView();
const x = mappedCoord[0];
const y = mappedCoord[1];
const xab = (x[0] + y[0]) / 2;
const yab = (x[1] + y[1]) / 2;
const midpoint = [xab, yab];
view.setCenter(midpoint);
}
_jumpToPolygonOrEllipse(coordinates) {
const pyramid = this.viewer[this._pyramid].metadata;
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
coordinates.forEach(coord => {
let mappedCoord = coordinateFormatScoord3d2Geometry(coord, pyramid);
const [x, y] = mappedCoord;
if (x < minX) {
minX = x;
} else if (x > maxX) {
maxX = x;
}
if (y < minY) {
minY = y;
} else if (y > maxY) {
maxY = y;
}
});
const width = maxX - minX;
const height = maxY - minY;
minX -= 0.5 * width;
maxX += 0.5 * width;
minY -= 0.5 * height;
maxY += 0.5 * height;
const map = this._getMap();
map.getView().fit([minX, minY, maxX, maxY], map.getSize());
}
}
export { EVENTS };
export default ViewerManager;

View File

@ -0,0 +1,5 @@
// We need to define a UID for this extension as a device, and it should be the same for all saves:
const uid = '2.25.285241207697168520771311899641885187923';
export default uid;

View File

@ -0,0 +1,196 @@
import areaOfPolygon from './areaOfPolygon';
import { PubSubService } from '@ohif/core';
const EVENTS = {
LABEL_UPDATED: 'labelUpdated',
GRAPHIC_UPDATED: 'graphicUpdated',
VIEW_UPDATED: 'viewUpdated',
REMOVED: 'removed',
};
/**
* Represents a single annotation for the Microscopy Viewer
*/
class RoiAnnotation extends PubSubService {
constructor(
roiGraphic,
studyInstanceUID,
seriesInstanceUID,
label = '',
viewState = null
) {
super(EVENTS);
this.uid = roiGraphic.uid;
this.roiGraphic = roiGraphic;
this.studyInstanceUID = studyInstanceUID;
this.seriesInstanceUID = seriesInstanceUID;
this.label = label;
this.viewState = viewState;
this.setMeasurements(roiGraphic);
}
getScoord3d() {
const roiGraphic = this.roiGraphic;
const roiGraphicSymbols = Object.getOwnPropertySymbols(roiGraphic);
const _scoord3d = roiGraphicSymbols.find(
s => String(s) === 'Symbol(scoord3d)'
);
return roiGraphic[_scoord3d];
}
getCoordinates() {
const scoord3d = this.getScoord3d();
const scoord3dSymbols = Object.getOwnPropertySymbols(scoord3d);
const _coordinates = scoord3dSymbols.find(
s => String(s) === 'Symbol(coordinates)'
);
const coordinates = scoord3d[_coordinates];
return coordinates;
}
/**
* When called will trigger the REMOVED event
*/
destroy() {
this._broadcastEvent(EVENTS.REMOVED, this);
}
/**
* Updates the ROI graphic for the annotation and triggers the GRAPHIC_UPDATED
* event
*
* @param {Object} roiGraphic
*/
setRoiGraphic(roiGraphic) {
this.roiGraphic = roiGraphic;
this.setMeasurements();
this._broadcastEvent(EVENTS.GRAPHIC_UPDATED, this);
}
/**
* Update ROI measurement values based on its scoord3d coordinates.
*
* @returns {void}
*/
setMeasurements() {
const type = this.roiGraphic.scoord3d.graphicType;
const coordinates = this.roiGraphic.scoord3d.graphicData;
switch (type) {
case 'ELLIPSE':
// This is a circle so only need one side
const point1 = coordinates[0];
const point2 = coordinates[1];
let xLength2 = point2[0] - point1[0];
let yLength2 = point2[1] - point1[1];
xLength2 *= xLength2;
yLength2 *= yLength2;
const length = Math.sqrt(xLength2 + yLength2);
const radius = length / 2;
const areaEllipse = Math.PI * radius * radius;
this._area = areaEllipse;
this._length = undefined;
break;
case 'POLYGON':
const areaPolygon = areaOfPolygon(coordinates);
this._area = areaPolygon;
this._length = undefined;
break;
case 'POINT':
this._area = undefined;
this._length = undefined;
break;
case 'POLYLINE':
let len = 0;
for (let i = 1; i < coordinates.length; i++) {
const p1 = coordinates[i - 1];
const p2 = coordinates[i];
let xLen = p2[0] - p1[0];
let yLen = p2[1] - p1[1];
xLen *= xLen;
yLen *= yLen;
len += Math.sqrt(xLen + yLen);
}
this._area = undefined;
this._length = len;
break;
}
}
/**
* Update the OpenLayer Map's view state for the annotation and triggers the
* VIEW_UPDATED event
*
* @param {Object} viewState The new view state for the annotation
*/
setViewState(viewState) {
this.viewState = viewState;
this._broadcastEvent(EVENTS.VIEW_UPDATED, this);
}
/**
* Update the label for the annotation and triggers the LABEL_UPDATED event
*
* @param {String} label New label for the annotation
*/
setLabel(label, finding) {
this.label = label || (finding && finding.CodeMeaning);
this.finding = finding || {
CodingSchemeDesignator: '@ohif/extension-dicom-microscopy',
CodeValue: label,
CodeMeaning: label,
};
this._broadcastEvent(EVENTS.LABEL_UPDATED, this);
}
/**
* Returns the geometry type of the annotation concatenated with the label
* defined for the annotation.
* Difference with getDetailedLabel() is that this will return empty string for empy
* label.
*
* @returns {String} Text with geometry type and label
*/
getLabel() {
const label = this.label ? `${this.label}` : '';
return label;
}
/**
* Returns the geometry type of the annotation concatenated with the label
* defined for the annotation
*
* @returns {String} Text with geometry type and label
*/
getDetailedLabel() {
const label = this.label ? `${this.label}` : '(empty)';
return label;
}
getLength() {
return this._length;
}
getArea() {
return this._area;
}
}
export { EVENTS };
export default RoiAnnotation;

View File

@ -0,0 +1,17 @@
export default function areaOfPolygon(coordinates) {
// Shoelace algorithm.
const n = coordinates.length;
let area = 0.0;
let j = n - 1;
for (let i = 0; i < n; i++) {
area +=
(coordinates[j][0] + coordinates[i][0]) *
(coordinates[j][1] - coordinates[i][1]);
j = i; // j is previous vertex to i
}
// Return absolute value of half the sum
// (The value is halved as we are summing up triangles, not rectangles).
return Math.abs(area / 2.0);
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { Input, Dialog } from '@ohif/ui';
/**
*
* @param {*} data
* @param {*} data.text
* @param {*} data.label
* @param {*} event
* @param {func} callback
* @param {*} isArrowAnnotateInputDialog
*/
export default function callInputDialog({
uiDialogService,
title = 'Enter your annotation',
defaultValue = '',
callback = (value: string, action: string) => {}
}) {
const dialogId = 'microscopy-input-dialog';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.value, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: title,
value: { value: defaultValue },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<div className="p-4 bg-primary-dark">
<Input
autoFocus
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={value.defaultValue}
onChange={event => {
event.persist();
setValue(value => ({ ...value, value: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
</div>
);
},
},
});
}
}

View File

@ -0,0 +1,63 @@
function isPrimitive(v: any) {
return !(typeof v == 'object' || Array.isArray(v));
}
const vrNumerics = [
'DS',
'FL',
'FD',
'IS',
'OD',
'OF',
'OL',
'OV',
'SL',
'SS',
'SV',
'UL',
'US',
'UV',
];
/**
* Specialized for DICOM JSON format dataset cleaning.
* @param obj
* @returns
*/
export default function cleanDenaturalizedDataset(obj: any): any {
if (Array.isArray(obj)) {
const newAry = obj.map(o =>
isPrimitive(o) ? o : cleanDenaturalizedDataset(o)
);
return newAry;
} else if (isPrimitive(obj)) {
return obj;
} else {
Object.keys(obj).forEach(key => {
if (obj[key].Value === null && obj[key].vr) {
delete obj[key].Value;
} else if (Array.isArray(obj[key].Value) && obj[key].vr) {
if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) {
obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI;
// prevent mixed-content blockage
if (
window.location.protocol === 'https:' &&
obj[key].BulkDataURI.startsWith('http:')
) {
obj[key].BulkDataURI = obj[key].BulkDataURI.replace(
'http:',
'https:'
);
}
delete obj[key].Value;
} else if (vrNumerics.includes(obj[key].vr)) {
obj[key].Value = obj[key].Value.map(v => +v);
} else {
obj[key].Value = obj[key].Value.map(cleanDenaturalizedDataset);
}
}
});
return obj;
}
}

View File

@ -0,0 +1,213 @@
import dcmjs from 'dcmjs';
import DEVICE_OBSERVER_UID from './DEVICE_OBSERVER_UID';
/**
*
* @param {*} metadata - Microscopy Image instance metadata
* @param {*} SeriesDescription - SR description
* @param {*} annotations - Annotations
*
* @return Comprehensive3DSR dataset
*/
export default function constructSR(
metadata,
{ SeriesDescription, SeriesNumber },
annotations
) {
// Handle malformed data
if (!metadata.SpecimenDescriptionSequence) {
metadata.SpecimenDescriptionSequence = {
SpecimenUID: metadata.SeriesInstanceUID,
SpecimenIdentifier: metadata.SeriesDescription,
};
}
const { SpecimenDescriptionSequence } = metadata;
// construct Comprehensive3DSR dataset
const observationContext = new dcmjs.sr.templates.ObservationContext({
observerPersonContext: new dcmjs.sr.templates.ObserverContext({
observerType: new dcmjs.sr.coding.CodedConcept({
value: '121006',
schemeDesignator: 'DCM',
meaning: 'Person',
}),
observerIdentifyingAttributes: new dcmjs.sr.templates.PersonObserverIdentifyingAttributes(
{
name: '@ohif/extension-dicom-microscopy',
}
),
}),
observerDeviceContext: new dcmjs.sr.templates.ObserverContext({
observerType: new dcmjs.sr.coding.CodedConcept({
value: '121007',
schemeDesignator: 'DCM',
meaning: 'Device',
}),
observerIdentifyingAttributes: new dcmjs.sr.templates.DeviceObserverIdentifyingAttributes(
{
uid: DEVICE_OBSERVER_UID,
}
),
}),
subjectContext: new dcmjs.sr.templates.SubjectContext({
subjectClass: new dcmjs.sr.coding.CodedConcept({
value: '121027',
schemeDesignator: 'DCM',
meaning: 'Specimen',
}),
subjectClassSpecificContext: new dcmjs.sr.templates.SubjectContextSpecimen(
{
uid: SpecimenDescriptionSequence.SpecimenUID,
identifier:
SpecimenDescriptionSequence.SpecimenIdentifier ||
metadata.SeriesInstanceUID,
containerIdentifier:
metadata.ContainerIdentifier || metadata.SeriesInstanceUID,
}
),
}),
});
const imagingMeasurements = [];
for (let i = 0; i < annotations.length; i++) {
const { roiGraphic: roi, label } = annotations[i];
let {
measurements,
evaluations,
marker,
presentationState,
} = roi.properties;
console.debug('[SR] storing marker...', marker);
console.debug('[SR] storing measurements...', measurements);
console.debug('[SR] storing evaluations...', evaluations);
console.debug('[SR] storing presentation state...', presentationState);
if (presentationState) presentationState.marker = marker;
/** Avoid incompatibility with dcmjs */
measurements = measurements.map((measurement: any) => {
const ConceptName = Array.isArray(measurement.ConceptNameCodeSequence)
? measurement.ConceptNameCodeSequence[0]
: measurement.ConceptNameCodeSequence;
const MeasuredValue = Array.isArray(measurement.MeasuredValueSequence)
? measurement.MeasuredValueSequence[0]
: measurement.MeasuredValueSequence;
const MeasuredValueUnits = Array.isArray(
MeasuredValue.MeasurementUnitsCodeSequence
)
? MeasuredValue.MeasurementUnitsCodeSequence[0]
: MeasuredValue.MeasurementUnitsCodeSequence;
return new dcmjs.sr.valueTypes.NumContentItem({
name: new dcmjs.sr.coding.CodedConcept({
meaning: ConceptName.CodeMeaning,
value: ConceptName.CodeValue,
schemeDesignator: ConceptName.CodingSchemeDesignator,
}),
value: MeasuredValue.NumericValue,
unit: new dcmjs.sr.coding.CodedConcept({
value: MeasuredValueUnits.CodeValue,
meaning: MeasuredValueUnits.CodeMeaning,
schemeDesignator: MeasuredValueUnits.CodingSchemeDesignator,
}),
});
});
/** Avoid incompatibility with dcmjs */
evaluations = evaluations.map((evaluation: any) => {
const ConceptName = Array.isArray(evaluation.ConceptNameCodeSequence)
? evaluation.ConceptNameCodeSequence[0]
: evaluation.ConceptNameCodeSequence;
return new dcmjs.sr.valueTypes.TextContentItem({
name: new dcmjs.sr.coding.CodedConcept({
value: ConceptName.CodeValue,
meaning: ConceptName.CodeMeaning,
schemeDesignator: ConceptName.CodingSchemeDesignator,
}),
value: evaluation.TextValue,
relationshipType: evaluation.RelationshipType,
});
});
const identifier = `ROI #${i + 1}`;
const group = new dcmjs.sr.templates.PlanarROIMeasurementsAndQualitativeEvaluations(
{
trackingIdentifier: new dcmjs.sr.templates.TrackingIdentifier({
uid: roi.uid,
identifier: presentationState
? identifier.concat(`(${JSON.stringify(presentationState)})`)
: identifier,
}),
referencedRegion: new dcmjs.sr.contentItems.ImageRegion3D({
graphicType: roi.scoord3d.graphicType,
graphicData: roi.scoord3d.graphicData,
frameOfReferenceUID: roi.scoord3d.frameOfReferenceUID,
}),
findingType: new dcmjs.sr.coding.CodedConcept({
value: label,
schemeDesignator: '@ohif/extension-dicom-microscopy',
meaning: 'FREETEXT',
}),
/** Evaluations will conflict with current tracking identifier */
/** qualitativeEvaluations: evaluations, */
measurements,
}
);
imagingMeasurements.push(...group);
}
const measurementReport = new dcmjs.sr.templates.MeasurementReport({
languageOfContentItemAndDescendants: new dcmjs.sr.templates.LanguageOfContentItemAndDescendants(
{}
),
observationContext,
procedureReported: new dcmjs.sr.coding.CodedConcept({
value: '112703',
schemeDesignator: 'DCM',
meaning: 'Whole Slide Imaging',
}),
imagingMeasurements,
});
const dataset = new dcmjs.sr.documents.Comprehensive3DSR({
content: measurementReport[0],
evidence: [metadata],
seriesInstanceUID: dcmjs.data.DicomMetaDictionary.uid(),
seriesNumber: SeriesNumber,
seriesDescription:
SeriesDescription || 'Whole slide imaging structured report',
sopInstanceUID: dcmjs.data.DicomMetaDictionary.uid(),
instanceNumber: 1,
manufacturer: 'dcmjs-org',
});
dataset.SpecificCharacterSet = 'ISO_IR 192';
const fileMetaInformationVersionArray = new Uint8Array(2);
fileMetaInformationVersionArray[1] = 1;
dataset._meta = {
FileMetaInformationVersion: {
Value: [fileMetaInformationVersionArray.buffer], // TODO
vr: 'OB',
},
MediaStorageSOPClassUID: dataset.sopClassUID,
MediaStorageSOPInstanceUID: dataset.sopInstanceUID,
TransferSyntaxUID: {
Value: ['1.2.840.10008.1.2.1'],
vr: 'UI',
},
ImplementationClassUID: {
Value: [dcmjs.data.DicomMetaDictionary.uid()],
vr: 'UI',
},
ImplementationVersionName: {
Value: ['@ohif/extension-dicom-microscopy'],
vr: 'SH',
},
};
return dataset;
}

View File

@ -0,0 +1,110 @@
import { inv, multiply } from 'mathjs';
// TODO -> This is pulled out of some internal logic from Dicom Microscopy Viewer,
// We should likely just expose this there.
export default function coordinateFormatScoord3d2Geometry(
coordinates,
pyramid
) {
let transform = false;
if (!Array.isArray(coordinates[0])) {
coordinates = [coordinates];
transform = true;
}
const metadata = pyramid[pyramid.length - 1];
const orientation = metadata.ImageOrientationSlide;
const spacing = _getPixelSpacing(metadata);
const origin = metadata.TotalPixelMatrixOriginSequence[0];
const offset = [
Number(origin.XOffsetInSlideCoordinateSystem),
Number(origin.YOffsetInSlideCoordinateSystem),
];
coordinates = coordinates.map(c => {
const slideCoord = [c[0], c[1]];
const pixelCoord = mapSlideCoord2PixelCoord({
offset,
orientation,
spacing,
point: slideCoord,
});
return [pixelCoord[0], -(pixelCoord[1] + 1), 0];
});
if (transform) {
return coordinates[0];
}
return coordinates;
}
function _getPixelSpacing(metadata) {
if (metadata.PixelSpacing) return metadata.PixelSpacing;
const functionalGroup = metadata.SharedFunctionalGroupsSequence[0];
const pixelMeasures = functionalGroup.PixelMeasuresSequence[0];
return pixelMeasures.PixelSpacing;
}
function mapSlideCoord2PixelCoord(options) {
// X and Y Offset in Slide Coordinate System
if (!('offset' in options)) {
throw new Error('Option "offset" is required.');
}
if (!Array.isArray(options.offset)) {
throw new Error('Option "offset" must be an array.');
}
if (options.offset.length !== 2) {
throw new Error('Option "offset" must be an array with 2 elements.');
}
const offset = options.offset;
// Image Orientation Slide with direction cosines for Row and Column direction
if (!('orientation' in options)) {
throw new Error('Option "orientation" is required.');
}
if (!Array.isArray(options.orientation)) {
throw new Error('Option "orientation" must be an array.');
}
if (options.orientation.length !== 6) {
throw new Error('Option "orientation" must be an array with 6 elements.');
}
const orientation = options.orientation;
// Pixel Spacing along the Row and Column direction
if (!('spacing' in options)) {
throw new Error('Option "spacing" is required.');
}
if (!Array.isArray(options.spacing)) {
throw new Error('Option "spacing" must be an array.');
}
if (options.spacing.length !== 2) {
throw new Error('Option "spacing" must be an array with 2 elements.');
}
const spacing = options.spacing;
// X and Y coordinate in the Slide Coordinate System
if (!('point' in options)) {
throw new Error('Option "point" is required.');
}
if (!Array.isArray(options.point)) {
throw new Error('Option "point" must be an array.');
}
if (options.point.length !== 2) {
throw new Error('Option "point" must be an array with 2 elements.');
}
const point = options.point;
const m = [
[orientation[0] * spacing[1], orientation[3] * spacing[0], offset[0]],
[orientation[1] * spacing[1], orientation[4] * spacing[0], offset[1]],
[0, 0, 1],
];
const mInverted = inv(m);
const vSlide = [[point[0]], [point[1]], [1]];
const vImage = multiply(mInverted, vSlide);
const row = Number(vImage[1][0].toFixed(4));
const col = Number(vImage[0][0].toFixed(4));
return [col, row];
}

View File

@ -0,0 +1,14 @@
const DCM_CODE_VALUES = {
IMAGING_MEASUREMENTS: '126010',
MEASUREMENT_GROUP: '125007',
IMAGE_REGION: '111030',
FINDING: '121071',
TRACKING_UNIQUE_IDENTIFIER: '112039',
LENGTH: '410668003',
AREA: '42798000',
SHORT_AXIS: 'G-A186',
LONG_AXIS: 'G-A185',
ELLIPSE_AREA: 'G-D7FE', // TODO: Remove this
};
export default DCM_CODE_VALUES;

View File

@ -0,0 +1,96 @@
import { api } from 'dicomweb-client';
import { errorHandler, DicomMetadataStore } from '@ohif/core';
/**
* create a DICOMwebClient object to be used by Dicom Microscopy Viewer
*
* Referenced the code from `/extensions/default/src/DicomWebDataSource/index.js`
*
* @param param0
* @returns
*/
export default function getDicomWebClient({
extensionManager,
servicesManager,
}) {
const dataSourceConfig = window.config.dataSources.find(
ds => ds.sourceName === extensionManager.activeDataSource
);
const { userAuthenticationService } = servicesManager.services;
const { wadoRoot, staticWado, singlepart } = dataSourceConfig.configuration;
const wadoConfig = {
url: wadoRoot || '/dicomlocal',
staticWado,
singlepart,
headers: userAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
};
const client = new api.DICOMwebClient(wadoConfig);
client.wadoURL = wadoConfig.url;
if (extensionManager.activeDataSource === 'dicomlocal') {
/**
* For local data source, override the retrieveInstanceFrames() method of the
* dicomweb-client to retrieve image data from memory cached metadata.
* Other methods of the client doesn't matter, as we are feeding the DMV
* with the series metadata already.
*
* @param {Object} options
* @param {String} options.studyInstanceUID - Study Instance UID
* @param {String} options.seriesInstanceUID - Series Instance UID
* @param {String} options.sopInstanceUID - SOP Instance UID
* @param {String} options.frameNumbers - One-based indices of Frame Items
* @param {Object} [options.queryParams] - HTTP query parameters
* @returns {ArrayBuffer[]} Rendered Frame Items as byte arrays
*/
//
client.retrieveInstanceFrames = async options => {
if (!('studyInstanceUID' in options)) {
throw new Error(
'Study Instance UID is required for retrieval of instance frames'
);
}
if (!('seriesInstanceUID' in options)) {
throw new Error(
'Series Instance UID is required for retrieval of instance frames'
);
}
if (!('sopInstanceUID' in options)) {
throw new Error(
'SOP Instance UID is required for retrieval of instance frames'
);
}
if (!('frameNumbers' in options)) {
throw new Error(
'frame numbers are required for retrieval of instance frames'
);
}
console.log(
`retrieve frames ${options.frameNumbers.toString()} of instance ${
options.sopInstanceUID
}`
);
const instance = DicomMetadataStore.getInstance(
options.studyInstanceUID,
options.seriesInstanceUID,
options.sopInstanceUID
);
const frameNumbers = Array.isArray(options.frameNumbers)
? options.frameNumbers
: options.frameNumbers.split(',');
return frameNumbers.map(fr =>
Array.isArray(instance.PixelData)
? instance.PixelData[+fr - 1]
: instance.PixelData
);
};
}
return client;
}

View File

@ -0,0 +1,38 @@
/**
* Get referenced SM displaySet from SR displaySet
*
* @param {*} allDisplaySets
* @param {*} microscopySRDisplaySet
* @returns
*/
export default function getSourceDisplaySet(
allDisplaySets,
microscopySRDisplaySet
) {
const { ReferencedFrameOfReferenceUID } = microscopySRDisplaySet;
const otherDisplaySets = allDisplaySets.filter(
ds =>
ds.displaySetInstanceUID !== microscopySRDisplaySet.displaySetInstanceUID
);
const referencedDisplaySet = otherDisplaySets.find(
displaySet =>
displaySet.Modality === 'SM' &&
(displaySet.FrameOfReferenceUID === ReferencedFrameOfReferenceUID ||
// sometimes each depth instance has the different FrameOfReferenceID
displaySet.othersFrameOfReferenceUID.includes(
ReferencedFrameOfReferenceUID
))
);
if (!referencedDisplaySet && otherDisplaySets.length >= 1) {
console.warn(
'No display set with FrameOfReferenceUID',
ReferencedFrameOfReferenceUID,
'single series, assuming data error, defaulting to only series.'
);
return otherDisplaySets.find(displaySet => displaySet.Modality === 'SM');
}
return referencedDisplaySet;
}

View File

@ -0,0 +1,207 @@
import dcmjs from 'dcmjs';
import DCM_CODE_VALUES from './dcmCodeValues';
import toArray from './toArray';
const MeasurementReport =
dcmjs.adapters.DICOMMicroscopyViewer.MeasurementReport;
// Define as async so that it returns a promise, expected by the ViewportGrid
export default async function loadSR(
microscopyService,
microscopySRDisplaySet,
referencedDisplaySet
) {
const naturalizedDataset = microscopySRDisplaySet.metadata;
const { StudyInstanceUID, FrameOfReferenceUID } = referencedDisplaySet;
const managedViewers = microscopyService.getManagedViewersForStudy(
StudyInstanceUID
);
if (!managedViewers || !managedViewers.length) {
return;
}
microscopySRDisplaySet.isLoaded = true;
const { rois, labels } = await _getROIsFromToolState(
naturalizedDataset,
FrameOfReferenceUID
);
const managedViewer = managedViewers[0];
for (let i = 0; i < rois.length; i++) {
// NOTE: When saving Microscopy SR, we are attaching identifier property
// to each ROI, and when read for display, it is coming in as "TEXT"
// evaluation.
// As the Dicom Microscopy Viewer will override styles for "Text" evalutations
// to hide all other geometries, we are going to manually remove that
// evaluation item.
const roi = rois[i];
const roiSymbols = Object.getOwnPropertySymbols(roi);
const _properties = roiSymbols.find(s => s.description === 'properties');
const properties = roi[_properties];
properties['evaluations'] = [];
managedViewer.addRoiGraphicWithLabel(roi, labels[i]);
}
}
async function _getROIsFromToolState(naturalizedDataset, FrameOfReferenceUID) {
const toolState = MeasurementReport.generateToolState(naturalizedDataset);
const tools = Object.getOwnPropertyNames(toolState);
const DICOMMicroscopyViewer = await import(
/* webpackChunkName: "dicom-microscopy-viewer" */ 'dicom-microscopy-viewer'
);
const measurementGroupContentItems = _getMeasurementGroups(
naturalizedDataset
);
const rois = [];
const labels = [];
tools.forEach(t => {
const toolSpecificToolState = toolState[t];
let scoord3d;
const capsToolType = t.toUpperCase();
const measurementGroupContentItemsForTool = measurementGroupContentItems.filter(
mg => {
const imageRegionContentItem = toArray(mg.ContentSequence).find(
ci =>
ci.ConceptNameCodeSequence.CodeValue ===
DCM_CODE_VALUES.IMAGE_REGION
);
return imageRegionContentItem.GraphicType === capsToolType;
}
);
toolSpecificToolState.forEach((coordinates, index) => {
const properties = {};
const options = {
coordinates,
frameOfReferenceUID: FrameOfReferenceUID,
};
if (t === 'Polygon') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Polygon(options);
} else if (t === 'Polyline') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Polyline(options);
} else if (t === 'Point') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Point(options);
} else if (t === 'Ellipse') {
scoord3d = new DICOMMicroscopyViewer.scoord3d.Ellipse(options);
} else {
throw new Error('Unsupported tool type');
}
const measurementGroup = measurementGroupContentItemsForTool[index];
const findingGroup = toArray(measurementGroup.ContentSequence).find(
ci => ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.FINDING
);
const trackingGroup = toArray(measurementGroup.ContentSequence).find(
ci =>
ci.ConceptNameCodeSequence.CodeValue ===
DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER
);
/**
* Extract presentation state from tracking identifier.
* Currently is stored in SR but should be stored in its tags.
*/
if (trackingGroup) {
const regExp = /\(([^)]+)\)/;
const matches = regExp.exec(trackingGroup.TextValue);
if (matches && matches[1]) {
properties.presentationState = JSON.parse(matches[1]);
properties.marker = properties.presentationState.marker;
}
}
let measurements = toArray(measurementGroup.ContentSequence).filter(ci =>
[
DCM_CODE_VALUES.LENGTH,
DCM_CODE_VALUES.AREA,
DCM_CODE_VALUES.SHORT_AXIS,
DCM_CODE_VALUES.LONG_AXIS,
DCM_CODE_VALUES.ELLIPSE_AREA,
].includes(ci.ConceptNameCodeSequence.CodeValue)
);
let evaluations = toArray(measurementGroup.ContentSequence).filter(ci =>
[DCM_CODE_VALUES.TRACKING_UNIQUE_IDENTIFIER].includes(
ci.ConceptNameCodeSequence.CodeValue
)
);
/**
* TODO: Resolve bug in DCMJS.
* ConceptNameCodeSequence should be a sequence with only one item.
*/
evaluations = evaluations.map(evaluation => {
const e = { ...evaluation };
e.ConceptNameCodeSequence = toArray(e.ConceptNameCodeSequence);
return e;
});
/**
* TODO: Resolve bug in DCMJS.
* ConceptNameCodeSequence should be a sequence with only one item.
*/
measurements = measurements.map(measurement => {
const m = { ...measurement };
m.ConceptNameCodeSequence = toArray(m.ConceptNameCodeSequence);
return m;
});
if (measurements && measurements.length) {
properties.measurements = measurements;
console.debug('[SR] retrieving measurements...', measurements);
}
if (evaluations && evaluations.length) {
properties.evaluations = evaluations;
console.debug('[SR] retrieving evaluations...', evaluations);
}
const roi = new DICOMMicroscopyViewer.roi.ROI({ scoord3d, properties });
rois.push(roi);
if (findingGroup) {
labels.push(findingGroup.ConceptCodeSequence.CodeValue);
} else {
labels.push('');
}
});
});
return { rois, labels };
}
function _getMeasurementGroups(naturalizedDataset) {
const { ContentSequence } = naturalizedDataset;
const imagingMeasurementsContentItem = ContentSequence.find(
ci =>
ci.ConceptNameCodeSequence.CodeValue ===
DCM_CODE_VALUES.IMAGING_MEASUREMENTS
);
const measurementGroupContentItems = toArray(
imagingMeasurementsContentItem.ContentSequence
).filter(
ci =>
ci.ConceptNameCodeSequence.CodeValue === DCM_CODE_VALUES.MEASUREMENT_GROUP
);
return measurementGroupContentItems;
}

View File

@ -0,0 +1,12 @@
/**
* Trigger file download from an array buffer
* @param buffer
* @param filename
*/
export function saveByteArray(buffer: ArrayBuffer, filename: string) {
var blob = new Blob([buffer], { type: 'application/dicom' });
var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
link.click();
}

View File

@ -0,0 +1,48 @@
const defaultFill = {
color: 'rgba(255,255,255,0.4)',
};
const emptyFill = {
color: 'rgba(255,255,255,0.0)',
};
const defaultStroke = {
color: 'rgb(0,255,0)',
width: 1.5,
};
const activeStroke = {
color: 'rgb(255,255,0)',
width: 1.5,
};
const defaultStyle = {
image: {
circle: {
fill: defaultFill,
stroke: activeStroke,
radius: 5,
},
},
fill: defaultFill,
stroke: activeStroke,
};
const emptyStyle = {
image: {
circle: {
fill: emptyFill,
stroke: defaultStroke,
radius: 5,
},
},
fill: emptyFill,
stroke: defaultStroke,
};
const styles = {
active: defaultStyle,
default: emptyStyle,
};
export default styles;

View File

@ -0,0 +1,3 @@
export default function toArray(item) {
return Array.isArray(item) ? item : [item];
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": true,
"module": "es6",
"target": "es5",
"jsx": "react",
"allowJs": true,
"moduleResolution": "node"
}
}

View File

@ -545,6 +545,7 @@ function _mapDisplaySets(
const thumbnailNoImageModalities = [
'SR',
'SEG',
'SM',
'RTSTRUCT',
'RTPLAN',
'RTDOSE',

104
modes/microscopy/.gitignore vendored Normal file
View File

@ -0,0 +1,104 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

View File

@ -0,0 +1,8 @@
{
"trailingComma": "es5",
"printWidth": 80,
"proseWrap": "always",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

View File

@ -0,0 +1,8 @@
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');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};

View File

@ -0,0 +1,62 @@
const path = require('path');
const pkg = require('../package.json');
const outputFile = 'index.umd.js';
const rootDir = path.resolve(__dirname, '../');
const outputFolder = path.join(__dirname, `../dist/umd/${pkg.name}/`);
// Todo: add ESM build for the mode in addition to umd build
const config = {
mode: 'production',
entry: rootDir + '/' + pkg.module,
devtool: 'source-map',
output: {
path: outputFolder,
filename: outputFile,
library: pkg.name,
libraryTarget: 'umd',
chunkFilename: '[name].chunk.js',
umdNamedDefine: true,
globalObject: "typeof self !== 'undefined' ? self : this",
},
externals: [
{
react: {
root: 'React',
commonjs2: 'react',
commonjs: 'react',
amd: 'react',
},
'@ohif/core': {
commonjs2: '@ohif/core',
commonjs: '@ohif/core',
amd: '@ohif/core',
root: '@ohif/core',
},
'@ohif/ui': {
commonjs2: '@ohif/ui',
commonjs: '@ohif/ui',
amd: '@ohif/ui',
root: '@ohif/ui',
},
},
],
module: {
rules: [
{
test: /(\.jsx|\.js|\.tsx|\.ts)$/,
loader: 'babel-loader',
exclude: /(node_modules|bower_components)/,
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
],
},
resolve: {
modules: [path.resolve('./node_modules'), path.resolve('./src')],
extensions: ['.json', '.js', '.jsx', '.tsx', '.ts'],
},
};
module.exports = config;

9
modes/microscopy/LICENSE Normal file
View File

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2023 microscopy (26860200+md-prog@users.noreply.github.com)
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.

View File

@ -0,0 +1,13 @@
# OHIF mode for microscopy
Mode for *DICOM VL Whole Slide Microscopy Image*.
This mode uses [OHIF extension for microscopy](../../extensions/dicom-microscopy/).
## Acknowledgements
- [DICOM Microscopy Viewer](https://github.com/ImagingDataCommons/dicom-microscopy-viewer) is a Vanilla JS library for web-based visualization of DICOM VL Whole Slide Microscopy Image datasets and derived information.
- [SLIM Viewer](https://github.com/imagingdatacommons/slim) is a single-page application for interactive visualization and annotation of digital whole slide microscopy images and derived image analysis results in standard DICOM format. The application is based on the dicom-microscopy-viewer JavaScript library and runs fully client side without any custom server components.
## License
MIT

View File

@ -0,0 +1,44 @@
module.exports = {
plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
[
// TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2
'@babel/preset-env',
{
modules: 'commonjs',
debug: false,
},
"@babel/preset-typescript",
],
'@babel/preset-react',
],
plugins: [
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
],
},
production: {
presets: [
// WebPack handles ES6 --> Target Syntax
['@babel/preset-env', { modules: false }],
'@babel/preset-react',
"@babel/preset-typescript",
],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
development: {
presets: [
// WebPack handles ES6 --> Target Syntax
['@babel/preset-env', { modules: false }],
'@babel/preset-react',
"@babel/preset-typescript",
],
plugins: ['react-hot-loader/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
};

View File

@ -0,0 +1,65 @@
{
"name": "@ohif/mode-microscopy",
"version": "3.0.0",
"description": "OHIF mode for DICOM microscopy",
"author": "Bill Wallace, md-prog",
"license": "MIT",
"main": "dist/umd/@ohif/mode-microscopy/index.umd.js",
"files": [
"dist/**",
"public/**",
"README.md"
],
"repository": "OHIF/Viewers",
"keywords": [
"ohif-mode"
],
"module": "src/index.tsx",
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:cornerstone": "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 --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/extension-dicom-microscopy": "^3.0.0"
},
"dependencies": {
"@babel/runtime": "^7.20.13"
},
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-object-rest-spread": "^7.17.3",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-arrow-functions": "^7.16.7",
"@babel/plugin-transform-regenerator": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/plugin-transform-typescript": "^7.13.0",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.13.0",
"babel-eslint": "^8.0.3",
"babel-loader": "^8.0.0-beta.4",
"babel-plugin-inline-react-svg": "^2.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^10.2.0",
"cross-env": "^7.0.3",
"dotenv": "^14.1.0",
"eslint": "^5.0.1",
"eslint-loader": "^2.0.0",
"webpack": "^5.50.0",
"webpack-merge": "^5.7.3",
"webpack-cli": "^4.7.2"
}
}

View File

@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View File

@ -0,0 +1,142 @@
import { hotkeys } from '@ohif/core';
import { id } from './id';
import toolbarButtons from './toolbarButtons';
const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
hangingProtocols: '@ohif/extension-default.hangingProtocolModule.default',
leftPanel: '@ohif/extension-default.panelModule.seriesList',
rightPanel: '@ohif/extension-default.panelModule.measure',
};
export const cornerstone = {
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
};
const dicomvideo = {
sopClassHandler:
'@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
const dicompdf = {
sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf',
viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf',
};
const extensionDependencies = {
// Can derive the versions at least process.env.from npm_package_version
'@ohif/extension-default': '^3.0.0',
'@ohif/extension-cornerstone': '^3.0.0',
'@ohif/extension-cornerstone-dicom-sr': '^3.0.0',
'@ohif/extension-dicom-pdf': '^3.0.1',
'@ohif/extension-dicom-video': '^3.0.1',
'@ohif/extension-dicom-microscopy': '^3.0.0',
};
function modeFactory() {
return {
// TODO: We're using this as a route segment
// We should not be.
id,
routeName: 'microscopy',
displayName: 'Microscopy',
/**
* Lifecycle hooks
*/
onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => {
const { toolbarService } = servicesManager.services;
toolbarService.init(extensionManager);
toolbarService.addButtons(toolbarButtons);
toolbarService.createButtonSection('primary', [
'MeasurementTools',
'dragPan',
]);
},
onModeExit: ({ servicesManager }) => {
const { toolbarService } = servicesManager.services;
toolbarService.reset();
},
validationTags: {
study: [],
series: [],
},
isValidMode: ({ modalities }) => {
const modalities_list = modalities.split('\\');
// Slide Microscopy and ECG modality not supported by basic mode yet
return modalities_list.includes('SM');
},
routes: [
{
path: 'microscopy',
/*init: ({ servicesManager, extensionManager }) => {
//defaultViewerRouteInit
},*/
layoutTemplate: ({ location, servicesManager }) => {
return {
id: ohif.layout,
props: {
leftPanels: [ohif.leftPanel],
leftPanelDefaultClosed: true, // we have problem with rendering thumbnails for microscopy images
rightPanelDefaultClosed: true, // we do not have the save microscopy measurements yet
rightPanels: [
'@ohif/extension-dicom-microscopy.panelModule.measure',
],
viewports: [
{
namespace:
'@ohif/extension-dicom-microscopy.viewportModule.microscopy-dicom',
displaySetsToDisplay: [
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler',
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler',
],
},
{
namespace: dicomvideo.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
},
{
namespace: dicompdf.viewport,
displaySetsToDisplay: [dicompdf.sopClassHandler],
},
],
},
};
},
},
],
extensions: extensionDependencies,
hangingProtocols: [ohif.hangingProtocols],
hangingProtocol: ['default'],
// Order is important in sop class handlers when two handlers both use
// the same sop class under different situations. In that case, the more
// general handler needs to come last. For this case, the dicomvideo must
// come first to remove video transfer syntax before ohif uses images
sopClassHandlers: [
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySopClassHandler',
'@ohif/extension-dicom-microscopy.sopClassHandlerModule.DicomMicroscopySRSopClassHandler',
dicomvideo.sopClassHandler,
dicompdf.sopClassHandler,
],
hotkeys: [...hotkeys.defaults.hotkeyBindings],
};
}
const mode = {
id,
modeFactory,
extensionDependencies,
};
export default mode;

View File

@ -0,0 +1,184 @@
// TODO: torn, can either bake this here; or have to create a whole new button type
/**
*
* @param {*} type - 'tool' | 'action' | 'toggle'
* @param {*} id
* @param {*} icon
* @param {*} label
*/
function _createButton(type, id, icon, label, commands, tooltip) {
return {
id,
icon,
label,
type,
commands,
tooltip,
};
}
const _createActionButton = _createButton.bind(null, 'action');
const _createToggleButton = _createButton.bind(null, 'toggle');
const _createToolButton = _createButton.bind(null, 'tool');
const toolbarButtons = [
// Measurement
{
id: 'MeasurementTools',
type: 'ohif.splitButton',
props: {
groupId: 'MeasurementTools',
isRadio: true, // ?
// Switch?
primary: _createToolButton(
'line',
'tool-length',
'Line',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'line',
},
context: 'MICROSCOPY',
},
],
'Line'
),
secondary: {
icon: 'chevron-down',
label: '',
isActive: true,
tooltip: 'More Measure Tools',
},
items: [
_createToolButton(
'line',
'tool-length',
'Line',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'line',
},
context: 'MICROSCOPY',
},
],
'Line Tool'
),
_createToolButton(
'point',
'tool-point',
'Point',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'point',
},
context: 'MICROSCOPY',
},
],
'Point Tool'
),
_createToolButton(
'polygon',
'tool-polygon',
'Polygon',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'polygon',
},
context: 'MICROSCOPY',
},
],
'Polygon Tool'
),
_createToolButton(
'circle',
'tool-circle',
'Circle',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'circle',
},
context: 'MICROSCOPY',
},
],
'Circle Tool'
),
_createToolButton(
'box',
'tool-rectangle',
'Box',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'box',
},
context: 'MICROSCOPY',
},
],
'Box Tool'
),
_createToolButton(
'freehandpolygon',
'tool-freehand-polygon',
'Freehand Polygon',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'freehandpolygon',
},
context: 'MICROSCOPY',
},
],
'Freehand Polygon Tool'
),
_createToolButton(
'freehandline',
'tool-freehand-line',
'Freehand Line',
[
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'freehandline',
},
context: 'MICROSCOPY',
},
],
'Freehand Line Tool'
),
],
},
},
// Pan...
{
id: 'dragPan',
type: 'ohif.radioGroup',
props: {
type: 'tool',
icon: 'tool-move',
label: 'Pan',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'dragPan',
},
context: 'MICROSCOPY',
},
],
},
},
];
export default toolbarButtons;

View File

@ -27,7 +27,7 @@ function createStudyMetadata(StudyInstanceUID) {
const series = createSeriesMetadata([instance]);
this.series.push(series);
const { Modality } = series;
if (this.ModalitiesInStudy.indexof(Modality) === -1) {
if (this.ModalitiesInStudy.indexOf(Modality) === -1) {
this.ModalitiesInStudy.push(Modality);
}
}

View File

@ -1,4 +1,10 @@
const LOW_PRIORITY_MODALITIES = Object.freeze(['SEG', 'KO', 'PR', 'SR', 'RTSTRUCT']);
const LOW_PRIORITY_MODALITIES = Object.freeze([
'SEG',
'KO',
'PR',
'SR',
'RTSTRUCT',
]);
export default function isLowPriorityModality(Modality) {
return LOW_PRIORITY_MODALITIES.includes(Modality);

View File

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
<g id="icon-freehand-sculpt" fill="none" stroke-width="1.5" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round">
<line id="svg_1" y2="2.559367" x2="10.184807" y1="4.467781" x1="8.81711" />
<line id="svg_4" y2="1.493836" x2="11.727442" y1="2.766112" x1="10.089386" />
<line id="svg_7" y2="1.080346" x2="13.047428" y1="1.748291" x1="11.345759" />
<line id="svg_8" y2="1.000829" x2="14.351511" y1="1.112153" x1="12.77707" />
<line id="svg_9" y2="1.350705" x2="15.242104" y1="0.905408" x1="13.969828" />
<line id="svg_10" y2="2.098167" x2="15.862339" y1="1.14396" x1="14.955842" />
<line id="svg_11" y2="3.195505" x2="16.41896" y1="1.939133" x1="15.766918" />
<line id="svg_12" y2="4.292843" x2="16.530284" y1="2.925147" x1="16.387153" />
<line id="svg_16" y2="5.644637" x2="16.196311" y1="3.831643" x1="16.593898" />
<line id="svg_18" y2="7.266789" x2="15.623787" y1="5.19934" x1="16.275829" />
<line id="svg_19" y2="10.813258" x2="14.526449" y1="6.726071" x1="15.766918" />
<line id="svg_20" y2="5.056209" x2="8.085552" y1="4.181519" x1="8.976145" />
<line id="svg_23" y2="5.326568" x2="7.481221" y1="4.78585" x1="8.403621" />
<line id="svg_24" y2="5.565119" x2="6.749662" y1="5.294761" x1="7.624352" />
<line id="svg_25" y2="5.994512" x2="5.429675" y1="5.533312" x1="6.956407" />
<line id="svg_27" y2="6.551133" x2="4.284627" y1="5.962706" x1="5.572807" />
<line id="svg_28" y2="7.584858" x2="3.044158" y1="6.392099" x1="4.427758" />
<line id="svg_29" y2="8.84123" x2="2.185372" y1="7.489437" x1="3.219096" />
<line id="svg_31" y2="10.606513" x2="1.644654" y1="8.602678" x1="2.280792" />
<line id="svg_32" y2="13.214679" x2="1.48562" y1="10.352058" x1="1.724171" />
<line id="svg_33" y2="14.375631" x2="1.676461" y1="12.992031" x1="1.453813" />
<line id="svg_34" y2="15.298031" x2="2.264889" y1="14.152983" x1="1.517427" />
<line id="svg_35" y2="16.172721" x2="3.521261" y1="14.948155" x1="1.915013" />
<line id="svg_36" y2="16.824762" x2="5.207027" y1="15.997783" x1="3.28271" />
<line id="svg_38" y2="17.063314" x2="7.035924" y1="16.745245" x1="4.968475" />
<line id="svg_39" y2="16.888376" x2="9.278311" y1="17.047411" x1="6.733758" />
<line id="svg_40" y2="16.284045" x2="10.661911" y1="16.983797" x1="8.992048" />
<line id="svg_41" y2="15.313934" x2="11.647925" y1="16.395369" x1="10.455166" />
<line id="svg_44" y2="13.898527" x2="12.82478" y1="15.425259" x1="11.504794" />
<line id="svg_45" y2="12.037824" x2="14.144766" y1="14.312017" x1="12.522614" />
<line id="svg_47" y2="10.59061" x2="14.605966" y1="12.228665" x1="13.953925" />
<ellipse ry="1" rx="1" id="svg_48" cy="3.982726" cx="13.460918" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
<g fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="miter">
<ellipse ry="1" rx="1" id="svg_3" cy="4.240343" cx="14.306499" />
<line id="svg_4" y2="3.58462" x2="12.242186" y1="3.997482" x1="13.432202" />
<line id="svg_5" y2="3.268901" x2="10.857882" y1="3.608906" x1="12.387902" />
<line id="svg_6" y2="3.147471" x2="9.740724" y1="3.293187" x1="10.955026" />
<line id="svg_7" y2="3.147471" x2="8.089274" y1="3.196043" x1="9.983585" />
<line id="svg_8" y2="3.268901" x2="6.874972" y1="3.123185" x1="8.307848" />
<line id="svg_9" y2="3.657478" x2="5.587812" y1="3.220329" x1="7.020688" />
<line id="svg_10" y2="4.046054" x2="4.737801" y1="3.560334" x1="5.854959" />
<line id="svg_11" y2="4.337487" x2="4.300652" y1="3.997482" x1="4.834945" />
<line id="svg_12" y2="4.726063" x2="3.88779" y1="4.191771" x1="4.470655" />
<line id="svg_15" y2="5.3575" x2="3.377783" y1="4.604633" x1="3.960648" />
<line id="svg_16" y2="6.183226" x2="2.916348" y1="5.138926" x1="3.547785" />
<line id="svg_17" y2="6.960379" x2="2.770632" y1="5.867507" x1="3.037779" />
<line id="svg_18" y2="7.713246" x2="2.673488" y1="6.741804" x1="2.819204" />
<line id="svg_19" y2="8.684687" x2="2.697774" y1="7.616102" x1="2.673488" />
<line id="svg_20" y2="9.753273" x2="2.892062" y1="8.611829" x1="2.697774" />
<line id="svg_21" y2="10.724714" x2="3.134923" y1="9.534698" x1="2.84349" />
<line id="svg_23" y2="11.647583" x2="3.596357" y1="10.578998" x1="3.086351" />
<line id="svg_25" y2="12.521881" x2="4.276366" y1="11.501867" x1="3.499213" />
<line id="svg_26" y2="13.930471" x2="5.830673" y1="12.376165" x1="4.13065" />
<line id="svg_28" y2="14.707624" x2="7.263549" y1="13.881899" x1="5.733528" />
<line id="svg_29" y2="15.339061" x2="8.963571" y1="14.61048" x1="7.06926" />
<line id="svg_30" y2="15.581921" x2="10.882168" y1="15.314775" x1="8.817855" />
<line id="svg_31" y2="15.460491" x2="12.023612" y1="15.581921" x1="10.785024" />
<line id="svg_33" y2="15.120487" x2="13.092197" y1="15.484777" x1="11.877895" />
<line id="svg_34" y2="14.586194" x2="13.86935" y1="15.217631" x1="12.897909" />
<line id="svg_35" y2="13.833327" x2="14.597931" y1="14.756196" x1="13.699348" />
<line id="svg_37" y2="12.716169" x2="15.180796" y1="13.881899" x1="14.549359" />
<line id="svg_39" y2="11.429009" x2="15.520801" y1="12.813313" x1="15.15651" />
<ellipse ry="1" rx="1" id="svg_40" cy="10.967574" cx="15.520801" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 50 50">
<polygon points="24 1, 46 18, 40 49, 9 49, 1 18" stroke="currentColor" stroke-width="3" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -97,6 +97,9 @@ import toolRectangle from './../../assets/icons/tool-rectangle.svg';
import toolFusionColor from './../../assets/icons/tool-fusion-color.svg';
import toolCreateThreshold from './../../assets/icons/tool-create-threshold.svg';
import toolCalibration from './../../assets/icons/tool-calibration.svg';
import toolFreehand from './../../assets/icons/tool-freehand.svg';
import toolFreehandPolygon from './../../assets/icons/tool-freehand-polygon.svg';
import toolPolygon from './../../assets/icons/tool-polygon.svg';
import editPatient from './../../assets/icons/edit-patient.svg';
import panelGroupMore from './../../assets/icons/panel-group-more.svg';
import panelGroupOpenClose from './../../assets/icons/panel-group-open-close.svg';
@ -216,6 +219,11 @@ const ICONS = {
'tool-fusion-color': toolFusionColor,
'tool-create-threshold': toolCreateThreshold,
'tool-calibration': toolCalibration,
'tool-point': toolCircle,
'tool-circle': toolCircle,
'tool-freehand-line': toolFreehand,
'tool-freehand-polygon': toolFreehandPolygon,
'tool-polygon': toolPolygon,
'edit-patient': editPatient,
'icon-mpr': iconMPR,
'icon-next-inactive': iconNextInactive,

View File

@ -10,7 +10,6 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { InjectManifest } = require('workbox-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyPlugin = require('copy-webpack-plugin');
// ~~ Directories
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
@ -101,6 +100,21 @@ module.exports = (env, argv) => {
from: `${PUBLIC_DIR}/${APP_CONFIG}`,
to: `${DIST_DIR}/app-config.js`,
},
// Copy Dicom Microscopy Viewer build files
{
from:
'../../../node_modules/dicom-microscopy-viewer/dist/dynamic-import',
to: DIST_DIR,
globOptions: {
ignore: ['*.js.map'],
},
},
// Copy dicom-image-loader build files
{
from:
'../../../node_modules/@cornerstonejs/dicom-image-loader/dist/dynamic-import',
to: DIST_DIR,
},
],
}),
// Generate "index.html" w/ correct includes/imports
@ -120,15 +134,6 @@ module.exports = (env, argv) => {
// Need to exclude the theme as it is updated independently
exclude: [/theme/],
}),
new CopyPlugin({
patterns: [
{
from:
'../../../node_modules/@cornerstonejs/dicom-image-loader/dist/dynamic-import',
to: DIST_DIR,
},
],
}),
],
// https://webpack.js.org/configuration/dev-server/
devServer: {

View File

@ -55,11 +55,13 @@
"@ohif/extension-default": "^3.0.0",
"@ohif/extension-dicom-pdf": "^3.0.1",
"@ohif/extension-dicom-video": "^3.0.1",
"@ohif/extension-dicom-microscopy": "^3.0.0",
"@ohif/extension-test": "0.0.1",
"@ohif/i18n": "^1.0.0",
"@ohif/mode-basic-dev-mode": "^3.0.0",
"@ohif/mode-test": "^0.0.1",
"@ohif/mode-longitudinal": "^3.0.0",
"@ohif/mode-microscopy": "^3.0.0",
"@ohif/ui": "^2.0.0",
"@types/react": "^17.0.38",
"classnames": "^2.3.2",

View File

@ -16,6 +16,10 @@
"packageName": "@ohif/extension-default",
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-dicom-microscopy",
"version": "3.0.0"
},
{
"packageName": "@ohif/extension-dicom-pdf",
"version": "3.0.1"
@ -42,6 +46,10 @@
"packageName": "@ohif/mode-longitudinal",
"version": "3.0.0"
},
{
"packageName": "@ohif/mode-microscopy",
"version": "3.0.0"
},
{
"packageName": "@ohif/mode-tmtv",
"version": "3.0.0"

View File

@ -1,5 +1,6 @@
window.config = {
routerBasename: '/',
modes: [],
extensions: [],
showStudyList: true,
// below flag is for performance reasons, but it might not work for all servers
@ -7,21 +8,24 @@ window.config = {
showWarningMessageForCrossOrigin: true,
strictZSpacingForVolumeViewport: true,
showCPUFallbackMessage: true,
servers: {
dicomWeb: [
{
dataSources: [
{
friendlyName: 'DCM4CHEE Server',
namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
sourceName: 'dicomweb',
configuration: {
name: 'DCM4CHEE',
wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb',
qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb',
wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
enableStudyLazyLoad: true,
useBulkDataURI: false,
},
],
},
},
],
defaultDataSourceName: 'dicomweb',
hotkeys: [
{
commandName: 'incrementActiveViewport',

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef } from 'react';
import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { MODULE_TYPES } from '@ohif/core';
import { DicomMetadataStore, MODULE_TYPES } from '@ohif/core';
import Dropzone from 'react-dropzone';
import filesToStudies from './filesToStudies';
@ -60,12 +60,41 @@ function Local() {
const firstLocalDataSource = localDataSources[0];
const dataSource = firstLocalDataSource.createDataSource({});
const microscopyExtensionLoaded = extensionManager.registeredExtensionIds.includes(
'@ohif/extension-dicom-microscopy'
);
let modePath = 'viewer';
const onDrop = async acceptedFiles => {
const studies = await filesToStudies(acceptedFiles, dataSource);
// Todo: navigate to work list and let user select a mode
const query = new URLSearchParams();
if (microscopyExtensionLoaded) {
// TODO: for microscopy, we are forcing microscopy mode, which is not ideal.
// we should make the local drag and drop navigate to the worklist and
// there user can select microscopy mode
const smStudies = studies.filter(id => {
const study = DicomMetadataStore.getStudy(id);
return (
study.series.findIndex(
s => s.Modality === 'SM' || s.instances[0].Modality === 'SM'
) >= 0
);
});
if (smStudies.length > 0) {
smStudies.forEach(id => query.append('StudyInstanceUIDs', id));
modePath = 'microscopy';
}
}
// Todo: navigate to work list and let user select a mode
studies.forEach(id => query.append('StudyInstanceUIDs', id));
navigate(`/viewer/dicomlocal?${decodeURIComponent(query.toString())}`);
navigate(`/${modePath}/dicomlocal?${decodeURIComponent(query.toString())}`);
};
// Set body style

299
yarn.lock
View File

@ -1272,7 +1272,7 @@
core-js-pure "^3.25.1"
regenerator-runtime "^0.13.11"
"@babel/runtime@7.17.9", "@babel/runtime@7.7.6", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
"@babel/runtime@7.17.9", "@babel/runtime@7.7.6", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.21.0"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
@ -1387,19 +1387,34 @@
resolved "https://registry.npmjs.org/@cornerstonejs/calculate-suv/-/calculate-suv-1.0.3.tgz#6d99a72032c0f90cebf44dc6f0b12a5f1102e884"
integrity sha512-2SwVJKzC1DzyxdxJtCht9dhTND2GFjLwhhkDyyC7vJq5tIgbhxgPk1CSwovO1pxmoybAXzjOxnaubllxLgoT+w==
"@cornerstonejs/codec-charls@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-charls/-/codec-charls-0.1.1.tgz#e55d4aa908732d0cc902888b7f3856c5a996df7f"
integrity sha512-Y250DGVzmownJ7WgpHxNqWvfTnv4/malaKm/tWm0xE1FxhQE8iErMWFpKxpNDk3MdfXO4/98piVsUwmJMiWoDQ==
"@cornerstonejs/codec-charls@^1.2.3":
version "1.2.3"
resolved "https://registry.npmjs.org/@cornerstonejs/codec-charls/-/codec-charls-1.2.3.tgz#6952c420486822ac8404409ae0ed5a559aff6e25"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-charls/-/codec-charls-1.2.3.tgz#6952c420486822ac8404409ae0ed5a559aff6e25"
integrity sha512-qKUe6DN0dnGzhhfZLYhH9UZacMcudjxcaLXCrpxJImT/M/PQvZCT2rllu6VGJbWKJWG+dMVV2zmmleZcdJ7/cA==
"@cornerstonejs/codec-libjpeg-turbo-8bit@^0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-libjpeg-turbo-8bit/-/codec-libjpeg-turbo-8bit-0.0.7.tgz#2ea9b575eed19e6e7e3701b7a50a4ae0ffbef0c4"
integrity sha512-qgm6BuVAy5mNP8SJ+A6+VbmPnqgj8jPvJrw4HbUoAzndmf9/VHjTYwawn3kmZWya5ErFAsXQ6c0U0noB1LKAiA==
"@cornerstonejs/codec-libjpeg-turbo-8bit@^1.2.2":
version "1.2.2"
resolved "https://registry.npmjs.org/@cornerstonejs/codec-libjpeg-turbo-8bit/-/codec-libjpeg-turbo-8bit-1.2.2.tgz#ae384b149d6655e3dd6e18b9891fab479ab5e144"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-libjpeg-turbo-8bit/-/codec-libjpeg-turbo-8bit-1.2.2.tgz#ae384b149d6655e3dd6e18b9891fab479ab5e144"
integrity sha512-aAUMK2958YNpOb/7G6e2/aG7hExTiFTASlMt/v90XA0pRHdWiNg5ny4S5SAju0FbIw4zcMnR0qfY+yW3VG2ivg==
"@cornerstonejs/codec-openjpeg@^0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-openjpeg/-/codec-openjpeg-0.1.1.tgz#5bd1c52a33a425299299e970312731fa0cc2711b"
integrity sha512-HOMMOLV6xy8O/agNGGvrl0a8DwShpBvWxAzEzv2pqq12d3r5z/3MyIgNA3Oj/8bIBVvvVXxh9RX7rMDRHJdowg==
"@cornerstonejs/codec-openjpeg@^1.2.2":
version "1.2.2"
resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjpeg/-/codec-openjpeg-1.2.2.tgz#f0b524235b5551426b46db197a37b06f8ac805d7"
resolved "https://registry.yarnpkg.com/@cornerstonejs/codec-openjpeg/-/codec-openjpeg-1.2.2.tgz#f0b524235b5551426b46db197a37b06f8ac805d7"
integrity sha512-b1O7lZacKXelgeV9n8XWZ7pTw3i4Bq4qQ26G5ahBjWoOw4QNcCrb5hPxWBxNB/I8AoNbJxAe+lyLtyQGfdrTbw==
"@cornerstonejs/codec-openjph@^2.4.2":
@ -3408,6 +3423,35 @@
npmlog "^4.1.2"
write-file-atomic "^2.3.0"
"@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"
integrity sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==
"@mapbox/mapbox-gl-style-spec@^13.23.1":
version "13.28.0"
resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.28.0.tgz#2ec226320a0f77856046e000df9b419303a56458"
integrity sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==
dependencies:
"@mapbox/jsonlint-lines-primitives" "~2.0.2"
"@mapbox/point-geometry" "^0.1.0"
"@mapbox/unitbezier" "^0.0.0"
csscolorparser "~1.0.2"
json-stringify-pretty-compact "^2.0.0"
minimist "^1.2.6"
rw "^1.3.3"
sort-object "^0.3.2"
"@mapbox/point-geometry@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2"
integrity sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==
"@mapbox/unitbezier@^0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz#15651bd553a67b8581fb398810c98ad86a34524e"
integrity sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==
"@mdx-js/mdx@^1.6.22":
version "1.6.22"
resolved "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba"
@ -3669,6 +3713,11 @@
resolved "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.24.0.tgz#b6e83333c437ac106386e10a776dc823c1a90f33"
integrity sha512-kfYxX0rHP5N2Da6HyfjRCVaeNahAO9XV5WD4SKWKKjdKVkV/Z5/XjVgSKlTBLSYxnWDzYJJ4UHZV43Mw+facMA==
"@petamoriken/float16@^3.4.7":
version "3.7.1"
resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.7.1.tgz#4a0cc0854a3a101cc2d697272f120e1a05975ce5"
integrity sha512-oXZOc+aePd0FnhTWk15pyqK+Do87n0TyLV1nxdEougE95X/WXWDqmQobfhgnSY7QsWn5euZUWuDVeTQvoQ5VNw==
"@philpl/buble@^0.19.7":
version "0.19.7"
resolved "https://registry.npmjs.org/@philpl/buble/-/buble-0.19.7.tgz#27231e6391393793b64bc1c982fc7b593198b893"
@ -7614,6 +7663,13 @@ colorette@^2.0.10, colorette@^2.0.14, colorette@^2.0.16, colorette@^2.0.19:
resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
colormap@^2.3:
version "2.3.2"
resolved "https://registry.yarnpkg.com/colormap/-/colormap-2.3.2.tgz#4422c1178ce563806e265b96782737be85815abf"
integrity sha512-jDOjaoEEmA9AgA11B/jCSAvYE95r3wRoAyTf3LEHGiUVlNHJaL1mRkf5AyLSpQBVGfTEPwGEqCIzL+kgr2WgNA==
dependencies:
lerp "^1.0.3"
colors@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
@ -7697,6 +7753,11 @@ compare-func@^2.0.0:
array-ify "^1.0.0"
dot-prop "^5.1.0"
complex.js@^2.1.1:
version "2.1.1"
resolved "https://registry.npmjs.org/complex.js/-/complex.js-2.1.1.tgz#0675dac8e464ec431fb2ab7d30f41d889fb25c31"
integrity sha512-8njCHOTtFFLtegk6zQo0kkVX1rngygb/KQI6z1qZxlFI3scluC+LVTCFbrkWjBv4vvLlbQ9t88IPMC6k95VTTg==
component-emitter@^1.2.1:
version "1.3.0"
resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
@ -8332,6 +8393,11 @@ css-what@^6.0.1, css-what@^6.1.0:
resolved "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
csscolorparser@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/csscolorparser/-/csscolorparser-1.0.3.tgz#b34f391eea4da8f3e98231e2ccd8df9c041f171b"
integrity sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==
cssdb@^7.1.0:
version "7.5.4"
resolved "https://registry.npmjs.org/cssdb/-/cssdb-7.5.4.tgz#e34dafee5184d67634604e345e389ca79ac179ea"
@ -8680,6 +8746,17 @@ dayjs@^1.10.4:
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz#4b296922642f70999544d1144a2c25730fce63e2"
integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==
dcmjs@^0.27:
version "0.27.0"
resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.27.0.tgz#2662818c8b20494e366583e6dd3577c20d04d6ff"
integrity sha512-26wtatOLh+0b0aFy9iOg7PdOLG9EHevn9nEOn7Aoo5l7P9aFAMZ3XAa9Q+NULzLE2Q7DcIf2TQvfyVtdzhQzeg==
dependencies:
"@babel/runtime-corejs2" "^7.17.8"
gl-matrix "^3.1.0"
lodash.clonedeep "^4.5.0"
loglevelnext "^3.0.1"
ndarray "^1.0.19"
dcmjs@^0.29.5:
version "0.29.6"
resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.29.6.tgz#6ca1543e74bae29657d1f7f2273407e23266c011"
@ -8743,6 +8820,11 @@ decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0:
resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
decimal.js@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
decode-named-character-reference@^1.0.0:
version "1.0.2"
resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e"
@ -9068,12 +9150,34 @@ dezalgo@^1.0.0:
asap "^2.0.0"
wrappy "1"
dicom-microscopy-viewer@^0.44.0:
version "0.44.0"
resolved "https://registry.npmjs.org/dicom-microscopy-viewer/-/dicom-microscopy-viewer-0.44.0.tgz#d4a9e985acb23c5b82a9aedbe379b39198b8fd55"
integrity sha512-7rcm8bXTcOLsXrhBPwWe5gf4Sj1Rz1lRh+ECcaznOEr/uvX6BTAYLqNJNmeAUMoKcadboEeDrmLihCwCteRSJQ==
dependencies:
"@cornerstonejs/codec-charls" "^0.1.1"
"@cornerstonejs/codec-libjpeg-turbo-8bit" "^0.0.7"
"@cornerstonejs/codec-openjpeg" "^0.1.1"
colormap "^2.3"
dcmjs "^0.27"
dicomicc "^0.1"
dicomweb-client "^0.8"
image-type "^4.1"
mathjs "^11.2"
ol "^7.1"
uuid "^9.0"
dicom-parser@^1.8.9:
version "1.8.21"
resolved "https://registry.npmjs.org/dicom-parser/-/dicom-parser-1.8.21.tgz#916fdc77776367976b8457cad462b5b7cf74eaea"
integrity sha512-lYCweHQDsC8UFpXErPlg86Px2A8bay0HiUY+wzoG3xv5GzgqVHU3lziwSc/Gzn7VV7y2KeP072SzCviuOoU02w==
dicomweb-client@^0.8.4:
dicomicc@^0.1:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dicomicc/-/dicomicc-0.1.0.tgz#c73acc60a8e2d73a20f462c8c7d0e1e0d977c486"
integrity sha512-kZejPGjLQ9NsgovSyVsiAuCpq6LofNR9Erc8Tt/vQAYGYCoQnTyWDlg5D0TJJQATKul7cSr9k/q0TF8G9qdDkQ==
dicomweb-client@^0.8, dicomweb-client@^0.8.4:
version "0.8.4"
resolved "https://registry.npmjs.org/dicomweb-client/-/dicomweb-client-0.8.4.tgz#3da814cedb9415facb50bc5f43af8d961a991c74"
integrity sha512-/6oY3/Fg9JyAlbTWuJOYbVqici3+nlZt43+Z/Y47RNiqLc028JcxNlY28u4VQqksxfB59f1hhNbsqsHyDT4vhw==
@ -9391,6 +9495,11 @@ duplexify@^3.4.2, duplexify@^3.5.0, duplexify@^3.6.0:
readable-stream "^2.0.0"
stream-shift "^1.0.0"
earcut@^2.2.3:
version "2.2.4"
resolved "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a"
integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@ -9703,6 +9812,11 @@ escape-html@^1.0.3, escape-html@~1.0.3:
resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
escape-latex@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/escape-latex/-/escape-latex-1.2.0.tgz#07c03818cf7dac250cce517f4fda1b001ef2bca1"
integrity sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==
escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
@ -10666,6 +10780,11 @@ file-system-cache@^2.0.0:
fs-extra "^11.1.0"
ramda "^0.28.0"
file-type@^10.10.0:
version "10.11.0"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-10.11.0.tgz#2961d09e4675b9fb9a3ee6b69e9cd23f43fd1890"
integrity sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@ -11133,6 +11252,19 @@ gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
geotiff@^2.0.7:
version "2.0.7"
resolved "https://registry.npmjs.org/geotiff/-/geotiff-2.0.7.tgz#358e578233af70bfb0b4dee62d599ad78fc5cfca"
integrity sha512-FKvFTNowMU5K6lHYY2f83d4lS2rsCNdpUC28AX61x9ZzzqPNaWFElWv93xj0eJFaNyOYA63ic5OzJ88dHpoA5Q==
dependencies:
"@petamoriken/float16" "^3.4.7"
lerc "^3.0.0"
pako "^2.0.4"
parse-headers "^2.0.2"
quick-lru "^6.1.1"
web-worker "^1.2.0"
xml-utils "^1.0.2"
get-caller-file@^2.0.1, get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@ -12151,7 +12283,7 @@ identity-obj-proxy@3.0.x:
dependencies:
harmony-reflect "^1.4.6"
ieee754@^1.1.13:
ieee754@^1.1.12, ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@ -12185,6 +12317,13 @@ image-size@^1.0.1:
dependencies:
queue "6.0.2"
image-type@^4.1:
version "4.1.0"
resolved "https://registry.yarnpkg.com/image-type/-/image-type-4.1.0.tgz#72a88d64ff5021371ed67b9a466442100be57cd1"
integrity sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==
dependencies:
file-type "^10.10.0"
immer@^9.0.7:
version "9.0.21"
resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
@ -13127,6 +13266,11 @@ jake@^10.8.5:
filelist "^1.0.1"
minimatch "^3.0.4"
javascript-natural-sort@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz#f9e2303d4507f6d74355a73664d1440fb5a0ef59"
integrity sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==
jest-canvas-mock@^2.1.0:
version "2.5.0"
resolved "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.5.0.tgz#3e60f87f77ddfa273cf8e7e4ea5f86fa827c7117"
@ -13752,6 +13896,11 @@ json-stable-stringify-without-jsonify@^1.0.1:
resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json-stringify-pretty-compact@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz#e77c419f52ff00c45a31f07f4c820c2433143885"
integrity sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==
json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@ -13928,6 +14077,11 @@ left-pad@^1.3.0:
resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"
integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==
lerc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lerc/-/lerc-3.0.0.tgz#36f36fbd4ba46f0abf4833799fff2e7d6865f5cb"
integrity sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==
lerna@^3.15.0:
version "3.22.1"
resolved "https://registry.npmjs.org/lerna/-/lerna-3.22.1.tgz#82027ac3da9c627fd8bf02ccfeff806a98e65b62"
@ -13952,6 +14106,11 @@ lerna@^3.15.0:
import-local "^2.0.0"
npmlog "^4.1.2"
lerp@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/lerp/-/lerp-1.0.3.tgz#a18c8968f917896de15ccfcc28d55a6b731e776e"
integrity sha512-70Rh4rCkJDvwWiTsyZ1HmJGvnyfFah4m6iTux29XmasRiZPDBpT9Cfa4ai73+uLZxnlKruUS62jj2lb11wURiA==
leven@^3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
@ -14476,6 +14635,11 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
mapbox-to-css-font@^2.4.1:
version "2.4.2"
resolved "https://registry.yarnpkg.com/mapbox-to-css-font/-/mapbox-to-css-font-2.4.2.tgz#a9e31b363ad8ca881cd339ca99f2d2a6b02ea5dd"
integrity sha512-f+NBjJJY4T3dHtlEz1wCG7YFlkODEjFIYlxDdLIDMNpkSksqTt+l/d4rjuwItxuzkuMFvPyrjzV2lxRM4ePcIA==
markdown-escapes@^1.0.0:
version "1.0.4"
resolved "https://registry.npmjs.org/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
@ -14496,6 +14660,21 @@ material-colors@^1.2.1:
resolved "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==
mathjs@^11.2:
version "11.8.0"
resolved "https://registry.yarnpkg.com/mathjs/-/mathjs-11.8.0.tgz#b02e66461ec068fadf1e90c221121704dc14d8f5"
integrity sha512-I7r8HCoqUGyEiHQdeOCF2m2k9N+tcOHO3cZQ3tyJkMMBQMFqMR7dMQEboBMJAiFW2Um3PEItGPwcOc4P6KRqwg==
dependencies:
"@babel/runtime" "^7.21.0"
complex.js "^2.1.1"
decimal.js "^10.4.3"
escape-latex "^1.2.0"
fraction.js "^4.2.0"
javascript-natural-sort "^0.7.1"
seedrandom "^3.0.5"
tiny-emitter "^2.1.0"
typed-function "^4.1.0"
mdast-squeeze-paragraphs@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz#7c4c114679c3bee27ef10b58e2e015be79f1ef97"
@ -15956,6 +16135,25 @@ oidc-client@1.11.5:
crypto-js "^4.0.0"
serialize-javascript "^4.0.0"
ol-mapbox-style@^9.2.0:
version "9.7.0"
resolved "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-9.7.0.tgz#38a4f7abc8f0a94f378dcdb7cefdcc69ca3f6287"
integrity sha512-YX3u8FBJHsRHaoGxmd724Mp5WPTuV7wLQW6zZhcihMuInsSdCX1EiZfU+8IAL7jG0pbgl5YgC0aWE/MXJcUXxg==
dependencies:
"@mapbox/mapbox-gl-style-spec" "^13.23.1"
mapbox-to-css-font "^2.4.1"
ol@^7.1:
version "7.3.0"
resolved "https://registry.npmjs.org/ol/-/ol-7.3.0.tgz#7ffb5f258dafa4a3e218208aad9054d61f6fe786"
integrity sha512-08vJE4xITKPazQ9qJjeqYjRngnM9s+1eSv219Pdlrjj3LpLqjEH386ncq+76Dw1oGPGR8eLVEePk7FEd9XqqMw==
dependencies:
earcut "^2.2.3"
geotiff "^2.0.7"
ol-mapbox-style "^9.2.0"
pbf "3.2.1"
rbush "^3.0.1"
on-finished@2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
@ -16301,6 +16499,11 @@ parse-github-repo-url@^1.3.0:
resolved "https://registry.npmjs.org/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz#9e7d8bb252a6cb6ba42595060b7bf6df3dbc1f50"
integrity sha512-bSWyzBKqcSL4RrncTpGsEKoJ7H8a4L3++ifTAbTFeMHyq2wRV+42DGmQcHIrJIvdcacjIOxEuKH/w4tthF17gg==
parse-headers@^2.0.2:
version "2.0.5"
resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.5.tgz#069793f9356a54008571eb7f9761153e6c770da9"
integrity sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==
parse-json@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
@ -16501,6 +16704,14 @@ pause-stream@0.0.11:
dependencies:
through "~2.3"
pbf@3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a"
integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==
dependencies:
ieee754 "^1.1.12"
resolve-protobuf-schema "^2.1.0"
peek-stream@^1.1.0:
version "1.1.3"
resolved "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz#3b35d84b7ccbbd262fff31dc10da56856ead6d67"
@ -17728,6 +17939,11 @@ proto-list@~1.2.1:
resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==
protocol-buffers-schema@^3.3.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03"
integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==
protocols@^1.4.0:
version "1.4.8"
resolved "https://registry.npmjs.org/protocols/-/protocols-1.4.8.tgz#48eea2d8f58d9644a4a32caae5d5db290a075ce8"
@ -17919,6 +18135,16 @@ quick-lru@^5.1.1:
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
quick-lru@^6.1.1:
version "6.1.1"
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.1.tgz#f8e5bf9010376c126c80c1a62827a526c0e60adf"
integrity sha512-S27GBT+F0NTRiehtbrgaSE1idUAJ5bX8dPAQTdylEyNlrdcH5X4Lz7Edz3DYzecbsCluD5zO8ZNEe04z3D3u6Q==
quickselect@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018"
integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@ -17958,6 +18184,13 @@ raw-body@2.5.1:
iconv-lite "0.4.24"
unpipe "1.0.0"
rbush@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf"
integrity sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==
dependencies:
quickselect "^2.0.0"
rc@1.2.8, rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8:
version "1.2.8"
resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@ -19043,6 +19276,13 @@ resolve-pathname@^3.0.0:
resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd"
integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==
resolve-protobuf-schema@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758"
integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==
dependencies:
protocol-buffers-schema "^3.3.1"
resolve-url@^0.2.1:
version "0.2.1"
resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
@ -19219,6 +19459,11 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
rw@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
rxjs@^6.3.3, rxjs@^6.4.0:
version "6.6.7"
resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
@ -19368,7 +19613,7 @@ section-matter@^1.0.0:
extend-shallow "^2.0.1"
kind-of "^6.0.0"
seedrandom@3.0.5:
seedrandom@3.0.5, seedrandom@^3.0.5:
version "3.0.5"
resolved "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7"
integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==
@ -19813,11 +20058,21 @@ socks@~2.3.2:
ip "1.1.5"
smart-buffer "^4.1.0"
sort-asc@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/sort-asc/-/sort-asc-0.1.0.tgz#ab799df61fc73ea0956c79c4b531ed1e9e7727e9"
integrity sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==
sort-css-media-queries@2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.1.0.tgz#7c85e06f79826baabb232f5560e9745d7a78c4ce"
integrity sha512-IeWvo8NkNiY2vVYdPa27MCQiR0MN0M80johAYFVxWWXQ44KU84WNxjslwBHmc/7ZL2ccwkM7/e6S5aiKZXm7jA==
sort-desc@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/sort-desc/-/sort-desc-0.1.1.tgz#198b8c0cdeb095c463341861e3925d4ee359a9ee"
integrity sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -19832,6 +20087,14 @@ sort-keys@^2.0.0:
dependencies:
is-plain-obj "^1.0.0"
sort-object@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/sort-object/-/sort-object-0.3.2.tgz#98e0d199ede40e07c61a84403c61d6c3b290f9e2"
integrity sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==
dependencies:
sort-asc "^0.1.0"
sort-desc "^0.1.1"
source-list-map@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
@ -20823,6 +21086,11 @@ timsort@^0.3.0:
resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
integrity sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==
tiny-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
tiny-invariant@^1.0.2:
version "1.3.1"
resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
@ -21084,6 +21352,11 @@ typed-array-length@^1.0.4:
for-each "^0.3.3"
is-typed-array "^1.1.9"
typed-function@^4.1.0:
version "4.1.0"
resolved "https://registry.npmjs.org/typed-function/-/typed-function-4.1.0.tgz#da4bdd8a6d19a89e22732f75e4a410860aaf9712"
integrity sha512-DGwUl6cioBW5gw2L+6SMupGwH/kZOqivy17E4nsh1JI9fKF87orMmlQx3KISQPmg3sfnOUGlwVkroosvgddrlg==
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@ -21580,7 +21853,7 @@ uuid@^8.3.2:
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^9.0.0:
uuid@^9.0, uuid@^9.0.0:
version "9.0.0"
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5"
integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==
@ -21779,6 +22052,11 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6"
integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
web-worker@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.2.0.tgz#5d85a04a7fbc1e7db58f66595d7a3ac7c9c180da"
integrity sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==
webgl-constants@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz#f9633ee87fea56647a60b9ce735cbdfb891c6855"
@ -22499,6 +22777,11 @@ xml-name-validator@^3.0.0:
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
xml-utils@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/xml-utils/-/xml-utils-1.3.0.tgz#f1043534e3ac3deda12ddab39f8442e16da98ebb"
integrity sha512-i4PIrX33Wd66dvwo4syicwlwmnr6wuvvn4f2ku9hA67C2Uk62Xubczuhct+Evnd12/DV71qKNeDdJwES8HX1RA==
xml@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"