├── .babelrc ├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .webpack ├── webpack.config.js └── webpack.dev.js ├── LICENSE ├── README.md ├── babel.config.js ├── examples ├── App.js ├── ExamplePageBasic.js ├── ExamplePageCustomOverlay.js ├── ExamplePageEscapeHatch.js ├── ExamplePageGrid.js ├── index.css ├── index.html ├── index.js └── initCornerstone.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── index.html ├── initCornerstone.js ├── manifest.json └── script-tag-index.html ├── rollup.config.js └── src ├── CornerstoneViewport ├── CornerstoneViewport.css └── CornerstoneViewport.js ├── ImageScrollbar ├── ImageScrollbar.css └── ImageScrollbar.js ├── LoadingIndicator ├── LoadingIndicator.css └── LoadingIndicator.js ├── ViewportOrientationMarkers ├── ViewportOrientationMarkers.css └── ViewportOrientationMarkers.js ├── ViewportOverlay ├── ViewportOverlay.css └── ViewportOverlay.js ├── helpers ├── areStringArraysEqual.js ├── formatDA.js ├── formatNumberPrecision.js ├── formatPN.js ├── formatTM.js ├── index.js └── isValidNumber.js ├── index.js ├── metadataProvider.js └── test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "ie": "11" 6 | }, 7 | }], 8 | "@babel/preset-react" 9 | ], 10 | "plugins": [ 11 | "@babel/plugin-proposal-class-properties" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Use the latest 2.1 version of CircleCI pipeline processing engine, see https://circleci.com/docs/2.0/configuration-reference/ 2 | version: 2.1 3 | 4 | defaults: &defaults 5 | working_directory: ~/repo 6 | # https://circleci.com/docs/2.0/circleci-images/#language-image-variants 7 | docker: 8 | - image: cimg/node:16.8.0 9 | environment: 10 | TERM: xterm # Enable colors in term 11 | 12 | jobs: 13 | CHECKOUT: 14 | <<: *defaults 15 | steps: 16 | - checkout 17 | - restore_cache: 18 | name: Restore Package Cache 19 | keys: 20 | - packages-v1-{{ .Branch }}-{{ checksum "package.json" }} 21 | - packages-v1-{{ .Branch }}- 22 | - packages-v1- 23 | - run: npm ci 24 | - save_cache: 25 | name: Save Package Cache 26 | paths: 27 | - ~/repo/node_modules 28 | key: packages-v1-{{ .Branch }}-{{ checksum "package.json" }} 29 | - persist_to_workspace: 30 | root: ~/repo 31 | paths: . 32 | 33 | BUILD_AND_TEST: 34 | <<: *defaults 35 | steps: 36 | - attach_workspace: 37 | at: ~/repo 38 | - run: npm run build 39 | # No tests yet :( 40 | # https://circleci.com/docs/2.0/collect-test-data/#karma 41 | # - store_test_results: 42 | # path: reports/junit 43 | # - store_artifacts: 44 | # path: reports/junit 45 | - persist_to_workspace: 46 | root: ~/repo 47 | paths: . 48 | 49 | NPM_PUBLISH: 50 | <<: *defaults 51 | steps: 52 | - attach_workspace: 53 | at: ~/repo 54 | - run: 55 | name: Avoid hosts unknown for github 56 | command: 57 | mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking 58 | no\n" > ~/.ssh/config 59 | - run: 60 | name: Publish using Semantic Release 61 | command: npx semantic-release 62 | 63 | workflows: 64 | version: 2 65 | 66 | # PULL REQUEST 67 | PULL_REQUEST: 68 | jobs: 69 | - CHECKOUT: 70 | filters: 71 | branches: 72 | ignore: 73 | - master 74 | - feature/* 75 | - hotfix/* 76 | - BUILD_AND_TEST: 77 | requires: 78 | - CHECKOUT 79 | 80 | # MERGE TO MASTER 81 | TEST_AND_RELEASE: 82 | jobs: 83 | - CHECKOUT: 84 | filters: 85 | branches: 86 | only: master 87 | - BUILD_AND_TEST: 88 | requires: 89 | - CHECKOUT 90 | - NPM_PUBLISH: 91 | requires: 92 | - BUILD_AND_TEST 93 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | "parser": "babel-eslint", 9 | "env": { 10 | "jest": true 11 | }, 12 | "settings": { 13 | "react": { 14 | "version": "detect" 15 | } 16 | }, 17 | "globals": { 18 | "context": true, 19 | "assert": true 20 | }, 21 | "rules": { 22 | "no-console": "warn", 23 | "no-undef": "warn", 24 | "no-unused-vars": "warn", 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "endOfLine": "auto" 29 | } 30 | ], 31 | // React 32 | // https://github.com/yannickcr/eslint-plugin-react#recommended 33 | "react/sort-comp": "warn" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .idea 24 | .yalc 25 | yalc.lock 26 | yarn.lock -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 80, 4 | "proseWrap": "always", 5 | "tabWidth": 2, 6 | "semi": true, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80, 120], 3 | 4 | // === 5 | // Spacing 6 | // === 7 | 8 | "editor.insertSpaces": true, 9 | "editor.tabSize": 2, 10 | "editor.trimAutoWhitespace": true, 11 | "files.trimTrailingWhitespace": true, 12 | "files.insertFinalNewline": true, 13 | "files.trimFinalNewlines": true, 14 | 15 | // === 16 | // Event Triggers 17 | // === 18 | 19 | "editor.formatOnSave": true, 20 | "eslint.autoFixOnSave": true, 21 | "eslint.run": "onSave", 22 | "eslint.validate": [ 23 | { "language": "javascript", "autoFix": true }, 24 | { "language": "javascriptreact", "autoFix": true } 25 | ], 26 | "prettier.disableLanguages": [], 27 | "workbench.colorCustomizations": {}, 28 | "editor.codeActionsOnSave": { 29 | "source.fixAll.eslint": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const autoprefixer = require('autoprefixer'); 4 | // Plugins 5 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 6 | .BundleAnalyzerPlugin; 7 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 8 | const vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core 9 | .rules; 10 | 11 | const ENTRY_VTK_EXT = path.join(__dirname, './../src/index.js'); 12 | const SRC_PATH = path.join(__dirname, './../src'); 13 | const OUT_PATH = path.join(__dirname, './../dist'); 14 | 15 | /** 16 | * `argv` are options from the CLI. They will override our config here if set. 17 | * `-d` - Development shorthand, sets `debug`, `devtool`, and `mode` 18 | * `-p` - Production shorthand, sets `minimize`, `NODE_ENV`, and `mode` 19 | */ 20 | module.exports = (env, argv) => { 21 | const isProdBuild = argv.mode !== 'development'; 22 | const outputFilename = isProdBuild ? '[name].umd.min.js' : '[name].umd.js'; 23 | 24 | return { 25 | entry: { 26 | vtkViewport: ENTRY_VTK_EXT, 27 | }, 28 | devtool: 'source-map', 29 | output: { 30 | path: OUT_PATH, 31 | filename: outputFilename, 32 | library: 'VTKViewport', 33 | libraryTarget: 'umd', 34 | globalObject: 'this', 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.(js|jsx)$/, 40 | exclude: /node_modules/, 41 | use: ['babel-loader'], 42 | }, 43 | { 44 | test: /\.css$/, 45 | exclude: /\.module\.css$/, 46 | use: [ 47 | 'style-loader', 48 | 'css-loader', 49 | { 50 | loader: 'postcss-loader', 51 | options: { 52 | plugins: () => [autoprefixer('last 2 version', 'ie >= 10')], 53 | }, 54 | }, 55 | ], 56 | }, 57 | ].concat(vtkRules), 58 | }, 59 | resolve: { 60 | modules: [path.resolve(__dirname, './../node_modules'), SRC_PATH], 61 | }, 62 | externals: [ 63 | // Used to build/load metadata 64 | { 65 | 'cornerstone-core': { 66 | commonjs: 'cornerstone-core', 67 | commonjs2: 'cornerstone-core', 68 | amd: 'cornerstone-core', 69 | root: 'cornerstone', 70 | }, 71 | // Vector 3 use 72 | 'cornerstone-math': { 73 | commonjs: 'cornerstone-math', 74 | commonjs2: 'cornerstone-math', 75 | amd: 'cornerstone-math', 76 | root: 'cornerstoneMath', 77 | }, 78 | // 79 | react: 'react', 80 | // https://webpack.js.org/guides/author-libraries/#external-limitations 81 | 'vtk.js/Sources': { 82 | commonjs: 'vtk.js', 83 | commonjs2: 'vtk.js', 84 | amd: 'vtk.js', 85 | root: 'vtk.js', 86 | }, 87 | }, 88 | ], 89 | node: { 90 | // https://github.com/webpack-contrib/style-loader/issues/200 91 | Buffer: false, 92 | }, 93 | plugins: [ 94 | // Uncomment to generate bundle analyzer 95 | new BundleAnalyzerPlugin(), 96 | // Show build progress 97 | new webpack.ProgressPlugin(), 98 | // Clear dist between builds 99 | // new CleanWebpackPlugin(), 100 | ], 101 | }; 102 | }; 103 | -------------------------------------------------------------------------------- /.webpack/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | // Plugins 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const PUBLIC_URL = process.env.PUBLIC_URL || '/'; 6 | const ENTRY_VIEWPORT = path.join(__dirname, './../src/index.js'); 7 | const ENTRY_EXAMPLES = path.join(__dirname, './../examples/index.js'); 8 | const SRC_PATH = path.join(__dirname, './../src'); 9 | const OUT_PATH = path.join(__dirname, './../dist'); 10 | 11 | module.exports = { 12 | entry: { 13 | examples: ENTRY_EXAMPLES, 14 | }, 15 | mode: 'development', 16 | devtool: 'eval', 17 | output: { 18 | path: OUT_PATH, 19 | filename: '[name].bundle.[hash].js', 20 | library: 'cornerstoneViewport', 21 | libraryTarget: 'umd', 22 | globalObject: 'this', 23 | clean: true 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.(js|jsx)$/, 29 | exclude: /node_modules/, 30 | use: ['babel-loader'], 31 | }, 32 | { 33 | test: /\.css$/, 34 | exclude: /\.module\.css$/, 35 | use: ['style-loader', 'css-loader'], 36 | }, 37 | ], 38 | }, 39 | resolve: { 40 | modules: [path.resolve(__dirname, './../node_modules'), SRC_PATH], 41 | alias: { 42 | '@cornerstone-viewport': ENTRY_VIEWPORT, 43 | }, 44 | fallback: { fs: false, path: false }, 45 | }, 46 | plugins: [ 47 | // Show build progress 48 | new webpack.ProgressPlugin(), 49 | new webpack.DefinePlugin({ 50 | 'process.env.PUBLIC_URL': JSON.stringify(process.env.PUBLIC_URL || '/'), 51 | }), 52 | // Uncomment to generate bundle analyzer 53 | // new BundleAnalyzerPlugin(), 54 | // Generate `index.html` with injected build assets 55 | new HtmlWebpackPlugin({ 56 | filename: 'index.html', 57 | template: path.resolve(__dirname, '..', 'examples', 'index.html'), 58 | templateParameters: { 59 | PUBLIC_URL: PUBLIC_URL, 60 | }, 61 | }), 62 | ], 63 | // Fix for `cornerstone-wado-image-loader` fs dep 64 | devServer: { 65 | hot: true, 66 | open: true, 67 | port: 3000, 68 | historyApiFallback: { 69 | disableDotRule: true, 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Open Health Imaging Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Deprecated] Use Cornerstone3D Instead https://cornerstonejs.org/ 2 | 3 | # react-cornerstone-viewport 4 | 5 | > Cornerstone medical image viewport component for React 6 | 7 | [![NPM](https://img.shields.io/npm/v/react-cornerstone-viewport.svg)](https://www.npmjs.com/package/react-cornerstone-viewport) 8 | 9 | Documentation and Examples: [https://react.cornerstonejs.org/](https://react.cornerstonejs.org/) 10 | 11 | ## Install 12 | 13 | ```bash 14 | ## NPM 15 | npm install --save react-cornerstone-viewport 16 | 17 | ## Yarn 18 | yarn add react-cornerstone-viewport 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```jsx 24 | import React, { Component } from 'react' 25 | 26 | import CornerstoneViewport from 'react-cornerstone-viewport' 27 | 28 | class Example extends Component { 29 | render () { 30 | return ( 31 | 32 | ) 33 | } 34 | } 35 | ``` 36 | 37 | ## License 38 | 39 | MIT © [OHIF](https://github.com/OHIF) 40 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | ie: '11', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react', 12 | ], 13 | plugins: [ 14 | '@babel/plugin-proposal-class-properties', 15 | '@babel/plugin-transform-runtime', 16 | ], 17 | }; 18 | -------------------------------------------------------------------------------- /examples/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'; 3 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 4 | import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 5 | // Routes 6 | import ExamplePageBasic from './ExamplePageBasic.js'; 7 | import ExamplePageGrid from './ExamplePageGrid.js'; 8 | import ExamplePageCustomOverlay from './ExamplePageCustomOverlay.js'; 9 | import ExamplePageEscapeHatch from './ExamplePageEscapeHatch.js'; 10 | 11 | /** 12 | * 13 | * 14 | * @param {*} { href, text } 15 | * @returns 16 | */ 17 | function LinkOut({ href, text }) { 18 | return ( 19 | 20 | {text} 21 | 22 | ); 23 | } 24 | 25 | /** 26 | * 27 | * 28 | * @param {*} { title, url, text, screenshotUrl } 29 | * @returns 30 | */ 31 | function ExampleEntry({ title, url, text, screenshotUrl }) { 32 | return ( 33 |
34 |
35 | {title} 36 |
37 |

{text}

38 |
39 |
40 | ); 41 | } 42 | 43 | function Index() { 44 | const style = { 45 | minHeight: '512px', 46 | }; 47 | 48 | const examples = [ 49 | { 50 | title: 'Props Documentation', 51 | url: '/props', 52 | text: 'COMING SOON', 53 | }, 54 | { 55 | title: 'Basic Usage', 56 | url: '/basic', 57 | text: 58 | 'How to render an array of DICOM images and setup common built-in tools.', 59 | }, 60 | { 61 | title: 'Grid Layout', 62 | url: '/grid', 63 | text: 'How to render multiple viewports and track the "active viewport".', 64 | }, 65 | { 66 | title: 'Custom Overlay and Loader Component', 67 | url: '/custom-overlay', 68 | text: 69 | 'Provide an alternative React Component to use in place of the built in overlay-text and loading indicator components.', 70 | }, 71 | { 72 | title: 'Escape Hatch', 73 | url: '/escape-hatch', 74 | text: 75 | 'How to access the created enabledElement so you can leverage cornerstone and cornerstone-tools APIs directly.', 76 | }, 77 | // MOST COMPLEX: (mini viewer) 78 | // - (mini viewer) Dynamic Grid + Global Tool Sync + Changing Tools 79 | // Misc. Other Props: (just list them all, prop-types, basic comments for docs) 80 | // - onElementEnabled (escape hatch) 81 | // - eventListeners 82 | // - isStackPrefetchEnabled 83 | // - react-resize-observer 84 | ]; 85 | 86 | const exampleComponents = examples.map(e => { 87 | return ; 88 | }); 89 | 90 | return ( 91 |
92 |
93 |

Cornerstone Viewport

94 |
95 |
96 |
97 |

98 | This is a set of re-usable components for displaying data with{' '} 99 | 103 | . 104 |

105 |
106 | 107 |
108 |

Examples

109 | {exampleComponents} 110 |
111 | 112 |
113 |

Configuring Cornerstone

114 |

115 | All of these examples assume that the cornerstone family of 116 | libraries have been imported and configured prior to use. Here is 117 | brief example of what that may look like in ES6: 118 |

119 | 124 | {`import dicomParser from 'dicom-parser'; 125 | import cornerstone from 'cornerstone-core'; 126 | import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; 127 | import cornerstoneMath from 'cornerstone-math'; 128 | import cornerstoneTools from 'cornerstone-tools'; 129 | import Hammer from 'hammerjs'; 130 | 131 | export default function initCornerstone() { 132 | 133 | // Cornerstone Tools 134 | cornerstoneTools.external.cornerstone = cornerstone; 135 | cornerstoneTools.external.Hammer = Hammer; 136 | cornerstoneTools.external.cornerstoneMath = cornerstoneMath; 137 | cornerstoneTools.init(); 138 | 139 | // Image Loader 140 | cornerstoneWADOImageLoader.external.cornerstone = cornerstone; 141 | cornerstoneWADOImageLoader.external.dicomParser = dicomParser; 142 | cornerstoneWADOImageLoader.webWorkerManager.initialize({ 143 | maxWebWorkers: navigator.hardwareConcurrency || 1, 144 | startWebWorkersOnDemand: true, 145 | taskConfiguration: { 146 | decodeTask: { 147 | initializeCodecsOnStartup: false, 148 | usePDFJS: false, 149 | strict: false, 150 | }, 151 | }, 152 | }); 153 | }`} 154 | 155 |
156 |
157 |
158 | ); 159 | } 160 | 161 | /** 162 | * 163 | * 164 | * @param {*} props 165 | * @returns 166 | */ 167 | function Example(props) { 168 | return ( 169 |
170 |
171 | Back to Examples 172 |
173 | {props.children} 174 |
175 | ); 176 | } 177 | 178 | function AppRouter() { 179 | const basic = () => Example({ children: }); 180 | const grid = () => Example({ children: }); 181 | const customOverlay = () => 182 | Example({ children: }); 183 | const escapeHatch = () => Example({ children: }); 184 | 185 | return ( 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | ); 197 | } 198 | 199 | export default class App extends Component { 200 | render() { 201 | return ; 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /examples/ExamplePageBasic.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import CornerstoneViewport from '@cornerstone-viewport'; 3 | 4 | // https://github.com/conorhastings/react-syntax-highlighter 5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 6 | import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 7 | 8 | class ExamplePageBasic extends Component { 9 | state = { 10 | tools: [ 11 | // Mouse 12 | { 13 | name: 'Wwwc', 14 | mode: 'active', 15 | modeOptions: { mouseButtonMask: 1 }, 16 | }, 17 | { 18 | name: 'Zoom', 19 | mode: 'active', 20 | modeOptions: { mouseButtonMask: 2 }, 21 | }, 22 | { 23 | name: 'Pan', 24 | mode: 'active', 25 | modeOptions: { mouseButtonMask: 4 }, 26 | }, 27 | // Scroll 28 | { name: 'StackScrollMouseWheel', mode: 'active' }, 29 | // Touch 30 | { name: 'PanMultiTouch', mode: 'active' }, 31 | { name: 'ZoomTouchPinch', mode: 'active' }, 32 | { name: 'StackScrollMultiTouch', mode: 'active' }, 33 | ], 34 | imageIds: [ 35 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.11.dcm', 36 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.12.dcm', 37 | ], 38 | }; 39 | 40 | render() { 41 | return ( 42 |
43 |

Basic Demo

44 |
45 | 50 |
51 | 52 |

Source / Usage

53 |
54 | 59 | {`state = { 60 | tools: [ 61 | // Mouse 62 | { 63 | name: 'Wwwc', 64 | mode: 'active', 65 | modeOptions: { mouseButtonMask: 1 }, 66 | }, 67 | { 68 | name: 'Zoom', 69 | mode: 'active', 70 | modeOptions: { mouseButtonMask: 2 }, 71 | }, 72 | { 73 | name: 'Pan', 74 | mode: 'active', 75 | modeOptions: { mouseButtonMask: 4 }, 76 | }, 77 | // Scroll 78 | { name: 'StackScrollMouseWheel', mode: 'active' }, 79 | // Touch 80 | { name: 'PanMultiTouch', mode: 'active' }, 81 | { name: 'ZoomTouchPinch', mode: 'active' }, 82 | { name: 'StackScrollMultiTouch', mode: 'active' }, 83 | ], 84 | imageIds: [ 85 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.11.dcm', 86 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.12.dcm', 87 | ], 88 | }; 89 | 90 | {/* RENDER */} 91 | `} 96 | 97 |
98 |
99 | ); 100 | } 101 | } 102 | 103 | export default ExamplePageBasic; 104 | -------------------------------------------------------------------------------- /examples/ExamplePageCustomOverlay.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import CornerstoneViewport from '@cornerstone-viewport'; 3 | import PropTypes from 'prop-types'; 4 | 5 | // https://github.com/conorhastings/react-syntax-highlighter 6 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 7 | import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 8 | 9 | class CustomOverlay extends Component { 10 | static propTypes = { 11 | scale: PropTypes.number.isRequired, 12 | windowWidth: PropTypes.number.isRequired, 13 | windowCenter: PropTypes.number.isRequired, 14 | imageId: PropTypes.string.isRequired, 15 | imageIndex: PropTypes.number.isRequired, 16 | stackSize: PropTypes.number.isRequired, 17 | }; 18 | 19 | render() { 20 | return ( 21 |
31 | 🎉🎉🎉 32 | {Object.keys(this.props).map(key => { 33 | const val = this.props[key]; 34 | return ( 35 |

36 | {key}: {val} 37 |

38 | ); 39 | })} 40 | 🎉🎉🎉 41 |
42 | ); 43 | } 44 | } 45 | 46 | class CustomLoader extends Component { 47 | render() { 48 | return ( 49 |
60 |
61 |
62 |
63 | ); 64 | } 65 | } 66 | class ExamplePageCustomOverlay extends Component { 67 | render() { 68 | return ( 69 |
70 |

Custom Overlay

71 |

72 | The most important thing to note here are the props received by the 73 | Custom Overlay component. 74 |

75 |
76 | 92 |
93 | 94 |

Source / Usage

95 |
96 | 101 | {`class CustomOverlay extends Component { 102 | static propTypes = { 103 | scale: PropTypes.number.isRequired, 104 | windowWidth: PropTypes.number.isRequired, 105 | windowCenter: PropTypes.number.isRequired, 106 | imageId: PropTypes.string.isRequired, 107 | imageIndex: PropTypes.number.isRequired, 108 | stackSize: PropTypes.number.isRequired, 109 | }; 110 | 111 | render() { 112 | return ( 113 |
123 | 🎉🎉🎉 124 | {Object.keys(this.props).map(key => { 125 | const val = this.props[key]; 126 | return ( 127 |

128 | {key}: {val} 129 |

130 | ); 131 | })} 132 | 🎉🎉🎉 133 |
134 | ); 135 | } 136 | } 137 | 138 | class CustomLoader extends Component { 139 | render() { 140 | return ( 141 |
152 |
153 |
154 |
155 | ); 156 | } 157 | } 158 | 159 | 160 | {/* RENDER */} 161 | `} 177 |
178 |
179 |
180 | ); 181 | } 182 | } 183 | 184 | export default ExamplePageCustomOverlay; 185 | -------------------------------------------------------------------------------- /examples/ExamplePageEscapeHatch.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import CornerstoneViewport from '@cornerstone-viewport'; 3 | import cornerstone from 'cornerstone-core'; 4 | 5 | // https://github.com/conorhastings/react-syntax-highlighter 6 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 7 | import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 8 | 9 | class ExamplePageEscapeHatch extends Component { 10 | state = { 11 | cornerstoneElement: undefined, 12 | }; 13 | 14 | render() { 15 | return ( 16 |
17 |

Escape Hatch

18 |
19 | { 32 | const cornerstoneElement = elementEnabledEvt.detail.element; 33 | 34 | // Save this for later 35 | this.setState({ 36 | cornerstoneElement, 37 | }); 38 | 39 | // Wait for image to render, then invert it 40 | cornerstoneElement.addEventListener( 41 | 'cornerstoneimagerendered', 42 | imageRenderedEvent => { 43 | const viewport = imageRenderedEvent.detail.viewport; 44 | const invertedViewport = Object.assign({}, viewport, { 45 | invert: true, 46 | }); 47 | 48 | cornerstone.setViewport(cornerstoneElement, invertedViewport); 49 | } 50 | ); 51 | }} 52 | style={{ minWidth: '100%', height: '512px', flex: '1' }} 53 | /> 54 |
55 | 56 |

Source / Usage

57 |

58 | The onElementEnabled event allows us to capture the point in time our 59 | element is enabled, and a reference to the element that was enabled. 60 | The bulk of the Cornerstone and CornerstoneTools APIs use the element 61 | as an identifier in API calls -- having access to it opens the door 62 | for more advanced/custom usage. 63 |

64 |

65 | Most notably, you can forego using the "tools" and "activeTool" props 66 | and instead manage things by hand. This can be particularly useful if 67 | you are leveraging Cornerstone Tool's{' '} 68 | 69 | globalToolSyncEnabled 70 | {' '} 71 | configuration property to manage and synchronize viewport tool 72 | modes/bindings. 73 |

74 | 75 |
76 | 81 | {` { 94 | const cornerstoneElement = elementEnabledEvt.detail.element; 95 | 96 | // Save this for later 97 | this.setState({ 98 | cornerstoneElement, 99 | }); 100 | 101 | // Wait for image to render, then invert it 102 | cornerstoneElement.addEventListener( 103 | 'cornerstoneimagerendered', 104 | imageRenderedEvent => { 105 | const viewport = imageRenderedEvent.detail.viewport; 106 | const invertedViewport = Object.assign({}, viewport, { 107 | invert: true, 108 | }); 109 | 110 | cornerstone.setViewport(cornerstoneElement, invertedViewport); 111 | } 112 | ); 113 | }} 114 | style={{ minWidth: '100%', height: '512px', flex: '1' }} 115 | />`} 116 | 117 |
118 |
119 | ); 120 | } 121 | } 122 | 123 | export default ExamplePageEscapeHatch; 124 | -------------------------------------------------------------------------------- /examples/ExamplePageGrid.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import CornerstoneViewport from '@cornerstone-viewport'; 3 | 4 | // https://github.com/conorhastings/react-syntax-highlighter 5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; 6 | import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; 7 | 8 | const stack1 = [ 9 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.7.dcm', 10 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.8.dcm', 11 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.9.dcm', 12 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.10.dcm', 13 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.11.dcm', 14 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.12.dcm', 15 | ]; 16 | 17 | const stack2 = [ 18 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.9.dcm', 19 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.10.dcm', 20 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.11.dcm', 21 | ]; 22 | 23 | class ExamplePageGrid extends Component { 24 | state = { 25 | activeViewportIndex: 0, 26 | viewports: [0, 1, 2, 3], 27 | tools: [ 28 | // Mouse 29 | { 30 | name: 'Wwwc', 31 | mode: 'active', 32 | modeOptions: { mouseButtonMask: 1 }, 33 | }, 34 | { 35 | name: 'Zoom', 36 | mode: 'active', 37 | modeOptions: { mouseButtonMask: 2 }, 38 | }, 39 | { 40 | name: 'Pan', 41 | mode: 'active', 42 | modeOptions: { mouseButtonMask: 4 }, 43 | }, 44 | 'Length', 45 | 'Angle', 46 | 'Bidirectional', 47 | 'FreehandRoi', 48 | 'Eraser', 49 | // Scroll 50 | { name: 'StackScrollMouseWheel', mode: 'active' }, 51 | // Touch 52 | { name: 'PanMultiTouch', mode: 'active' }, 53 | { name: 'ZoomTouchPinch', mode: 'active' }, 54 | { name: 'StackScrollMultiTouch', mode: 'active' }, 55 | ], 56 | imageIds: stack1, 57 | // FORM 58 | activeTool: 'Wwwc', 59 | imageIdIndex: 0, 60 | isPlaying: false, 61 | frameRate: 22, 62 | }; 63 | 64 | componentDidMount() {} 65 | 66 | render() { 67 | return ( 68 |
69 |

Grid Demo

70 |
71 | {this.state.viewports.map(vp => ( 72 | { 83 | this.setState({ 84 | activeViewportIndex: vp, 85 | }); 86 | }} 87 | /> 88 | ))} 89 |
90 | 91 | {/* FORM */} 92 |

Misc. Props

93 |

94 | Note, when we change the active stack, we also need to update the 95 | imageIdIndex prop to a value that falls within the new stack's range 96 | of possible indexes. 97 |

98 |
99 |
100 | {/* FIRST COLUMN */} 101 |
102 |
103 | 104 | 121 |
122 |
123 | 124 | 130 | this.setState({ imageIdIndex: parseInt(evt.target.value) }) 131 | } 132 | className="form-control" 133 | id="image-id-index" 134 | > 135 |
136 |
137 | 138 | 155 |
156 |
157 | {/* SECOND COLUMN */} 158 |
159 |
160 | 163 | 170 |
171 |
172 | 173 | 184 | 185 | { 190 | const frameRateInput = parseInt(evt.target.value); 191 | const frameRate = Math.max(Math.min(frameRateInput, 90), 1); 192 | 193 | this.setState({ frameRate }); 194 | }} 195 | /> 196 |
197 |
198 |
199 |
200 | 201 | {/* CODE SNIPPET */} 202 |

Source / Usage

203 |
204 | 209 | {`state = { 210 | activeViewportIndex: 0, 211 | viewports: [0, 1, 2, 3], 212 | tools: [ 213 | // Mouse 214 | { 215 | name: 'Wwwc', 216 | mode: 'active', 217 | modeOptions: { mouseButtonMask: 1 }, 218 | }, 219 | { 220 | name: 'Zoom', 221 | mode: 'active', 222 | modeOptions: { mouseButtonMask: 2 }, 223 | }, 224 | { 225 | name: 'Pan', 226 | mode: 'active', 227 | modeOptions: { mouseButtonMask: 4 }, 228 | }, 229 | 'Length', 230 | 'Angle', 231 | 'Bidirectional', 232 | 'FreehandRoi', 233 | 'Eraser', 234 | // Scroll 235 | { name: 'StackScrollMouseWheel', mode: 'active' }, 236 | // Touch 237 | { name: 'PanMultiTouch', mode: 'active' }, 238 | { name: 'ZoomTouchPinch', mode: 'active' }, 239 | { name: 'StackScrollMultiTouch', mode: 'active' }, 240 | ], 241 | imageIds: [ 242 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.9.dcm', 243 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.10.dcm', 244 | 'dicomweb://s3.amazonaws.com/lury/PTCTStudy/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.11.dcm', 245 | ], 246 | // FORM 247 | activeTool: 'Wwwc', 248 | imageIdIndex: 0, 249 | isPlaying: false, 250 | frameRate: 22, 251 | }; 252 | 253 | {/* RENDER */} 254 |
255 | {this.state.viewports.map(viewportIndex => ( 256 | { 267 | this.setState({ 268 | activeViewportIndex: viewportIndex, 269 | }); 270 | }} 271 | /> 272 | ))} 273 |
`} 274 |
275 |
276 |
277 | ); 278 | } 279 | } 280 | 281 | export default ExamplePageGrid; 282 | -------------------------------------------------------------------------------- /examples/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * DEMO STYLES 3 | * - Not exported as part of library 4 | */ 5 | html { 6 | box-sizing: border-box; 7 | } 8 | 9 | *, 10 | *:before, 11 | *:after { 12 | box-sizing: inherit; 13 | } 14 | 15 | body { 16 | margin : 0; 17 | padding : 0; 18 | font-family: sans-serif; 19 | } 20 | 21 | .viewport-wrapper { 22 | border: 2px solid black; 23 | } 24 | 25 | .viewport-wrapper.active { 26 | border: 2px solid dodgerblue; 27 | } 28 | 29 | /* Custom Loader with animation */ 30 | .lds-ripple { 31 | display : inline-block; 32 | position: relative; 33 | width : 64px; 34 | height : 64px; 35 | } 36 | 37 | .lds-ripple div { 38 | position : absolute; 39 | border : 4px solid blue; 40 | opacity : 1; 41 | border-radius: 50%; 42 | animation : lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 43 | } 44 | 45 | .lds-ripple div:nth-child(2) { 46 | animation-delay: -0.5s; 47 | } 48 | 49 | @keyframes lds-ripple { 50 | 0% { 51 | top : 28px; 52 | left : 28px; 53 | width : 0; 54 | height : 0; 55 | opacity: 1; 56 | } 57 | 58 | 100% { 59 | top : -1px; 60 | left : -1px; 61 | width : 58px; 62 | height : 58px; 63 | opacity: 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | Cornerstone Viewport 14 | 15 | 16 | 22 | 23 | 24 | 25 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import './index.css'; 5 | import App from './App'; 6 | import initCornerstone from './initCornerstone.js'; 7 | 8 | // 9 | initCornerstone(); 10 | ReactDOM.render(, document.getElementById('root')); 11 | -------------------------------------------------------------------------------- /examples/initCornerstone.js: -------------------------------------------------------------------------------- 1 | import dicomParser from 'dicom-parser'; 2 | import cornerstone from 'cornerstone-core'; 3 | import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; 4 | import cornerstoneMath from 'cornerstone-math'; 5 | import cornerstoneTools from 'cornerstone-tools'; 6 | import Hammer from 'hammerjs'; 7 | 8 | export default function initCornerstone() { 9 | // Cornertone Tools 10 | cornerstoneTools.external.cornerstone = cornerstone; 11 | cornerstoneTools.external.Hammer = Hammer; 12 | cornerstoneTools.external.cornerstoneMath = cornerstoneMath; 13 | 14 | // 15 | cornerstoneTools.init(); 16 | 17 | // Preferences 18 | const fontFamily = 19 | 'Work Sans, Roboto, OpenSans, HelveticaNeue-Light, Helvetica Neue Light, Helvetica Neue, Helvetica, Arial, Lucida Grande, sans-serif'; 20 | cornerstoneTools.textStyle.setFont(`16px ${fontFamily}`); 21 | cornerstoneTools.toolStyle.setToolWidth(2); 22 | cornerstoneTools.toolColors.setToolColor('rgb(255, 255, 0)'); 23 | cornerstoneTools.toolColors.setActiveColor('rgb(0, 255, 0)'); 24 | 25 | cornerstoneTools.store.state.touchProximity = 40; 26 | 27 | // IMAGE LOADER 28 | cornerstoneWADOImageLoader.external.cornerstone = cornerstone; 29 | cornerstoneWADOImageLoader.external.dicomParser = dicomParser; 30 | cornerstoneWADOImageLoader.webWorkerManager.initialize({ 31 | maxWebWorkers: navigator.hardwareConcurrency || 1, 32 | startWebWorkersOnDemand: true, 33 | taskConfiguration: { 34 | decodeTask: { 35 | initializeCodecsOnStartup: false, 36 | usePDFJS: false, 37 | strict: false, 38 | }, 39 | }, 40 | }); 41 | 42 | // Debug 43 | window.cornerstone = cornerstone; 44 | window.cornerstoneTools = cornerstoneTools; 45 | } 46 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # https://www.netlify.com/docs/netlify-toml-reference/ 2 | 3 | [build] 4 | # Directory (relative to root of your repo) that contains the deploy-ready 5 | # HTML files and assets generated by the build. If a base directory has 6 | # been specified, include it in the publish directory path. 7 | command = "npm run build:examples" 8 | publish = "dist" 9 | 10 | # COMMENT: NODE_VERSION in root `.nvmrc` takes priority 11 | [build.environment] 12 | NODE_VERSION = "16.8.0" 13 | 14 | # COMMENT: This a rule for Single Page Applications 15 | [[redirects]] 16 | from = "/*" 17 | to = "/index.html" 18 | status = 200 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cornerstone-viewport", 3 | "version": "0.2.2", 4 | "description": "Cornerstone medical image viewport component for React", 5 | "author": "Cornerstone Contributors", 6 | "license": "MIT", 7 | "repository": "cornerstonejs/react-cornerstone-viewport", 8 | "main": "dist/index.js", 9 | "browser": "dist/index.umd.js", 10 | "module": "dist/index.es.js", 11 | "jsnext:main": "dist/index.es.js", 12 | "engines": { 13 | "node": ">=8", 14 | "npm": ">=5" 15 | }, 16 | "scripts": { 17 | "build": "rollup -c", 18 | "build:release": "rollup -c", 19 | "build:examples": "webpack --progress --config ./.webpack/webpack.dev.js", 20 | "cm": "npx git-cz", 21 | "dev": "webpack serve --config ./.webpack/webpack.dev.js", 22 | "start": "npm run dev", 23 | "prepublishOnly": "npm run build && npm run build:release" 24 | }, 25 | "peerDependencies": { 26 | "cornerstone-core": "^2.6.0", 27 | "cornerstone-math": "^0.1.9", 28 | "cornerstone-tools": "^6.0.1", 29 | "cornerstone-wado-image-loader": "^4.0.4", 30 | "dicom-parser": "^1.8.8", 31 | "hammerjs": "^2.0.8", 32 | "react": "^17.0.2", 33 | "react-dom": "^17.0.2" 34 | }, 35 | "dependencies": { 36 | "classnames": "^2.3.1", 37 | "date-fns": "^2.23.0", 38 | "lodash.debounce": "^4.0.8", 39 | "prop-types": "^15.7.2", 40 | "react-resize-detector": "^6.7.6" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.15.5", 44 | "@babel/plugin-proposal-class-properties": "^7.14.5", 45 | "@babel/plugin-transform-runtime": "^7.15.0", 46 | "@babel/preset-env": "^7.15.6", 47 | "@babel/preset-react": "^7.14.5", 48 | "@svgr/rollup": "^5.5.0", 49 | "babel-eslint": "10.1.0", 50 | "babel-loader": "^8.2.2", 51 | "commitizen": "4.2.x", 52 | "cornerstone-core": "^2.6.0", 53 | "cornerstone-math": "^0.1.9", 54 | "cornerstone-tools": "^6.0.1", 55 | "cornerstone-wado-image-loader": "^4.0.4", 56 | "cross-env": "^7.0.3", 57 | "css-loader": "^6.2.0", 58 | "dicom-parser": "^1.8.8", 59 | "eslint": "7.32.0", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-config-react-app": "^6.0.0", 62 | "eslint-plugin-flowtype": "^6.0.1", 63 | "eslint-plugin-import": "^2.24.2", 64 | "eslint-plugin-jsx-a11y": "^6.4.1", 65 | "eslint-plugin-node": "^11.1.0", 66 | "eslint-plugin-prettier": "^4.0.0", 67 | "eslint-plugin-promise": "^5.1.0", 68 | "eslint-plugin-react": "^7.25.1", 69 | "eslint-plugin-react-hooks": "^4.2.0", 70 | "hammerjs": "^2.0.8", 71 | "html-webpack-plugin": "^5.3.2", 72 | "husky": "^7.0.2", 73 | "lint-staged": "^11.1.2", 74 | "prettier": "^2.4.0", 75 | "react": "^16.6.3", 76 | "react-dom": "^16.6.3", 77 | "react-router-dom": "^5.3.0", 78 | "react-syntax-highlighter": "^15.4.4", 79 | "rollup": "^2.56.3", 80 | "rollup-plugin-babel": "^4.4.0", 81 | "rollup-plugin-commonjs": "^10.1.0", 82 | "rollup-plugin-node-resolve": "^5.2.0", 83 | "rollup-plugin-peer-deps-external": "^2.2.4", 84 | "rollup-plugin-postcss": "^4.0.1", 85 | "rollup-plugin-url": "^3.0.1", 86 | "semantic-release": "^17.4.7", 87 | "style-loader": "^3.2.1", 88 | "webpack": "5.52.1", 89 | "webpack-cli": "^4.8.0", 90 | "webpack-dev-server": "^4.2.1", 91 | "webpack-merge": "^5.8.0" 92 | }, 93 | "husky": { 94 | "hooks": { 95 | "pre-commit": "lint-staged" 96 | } 97 | }, 98 | "lint-staged": { 99 | "src/**/*.{js,jsx,json,css}": [ 100 | "eslint --fix", 101 | "prettier --write", 102 | "git add" 103 | ] 104 | }, 105 | "browserslist": [ 106 | ">0.2%", 107 | "not dead", 108 | "not ie < 11", 109 | "not op_mini all" 110 | ], 111 | "files": [ 112 | "dist" 113 | ], 114 | "config": { 115 | "commitizen": { 116 | "path": "./node_modules/cz-conventional-changelog" 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | react-cornerstone-viewport 14 | 15 | 16 | 22 | 23 | 24 | 25 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /public/initCornerstone.js: -------------------------------------------------------------------------------- 1 | cornerstoneTools.external.cornerstone = cornerstone; 2 | cornerstoneTools.external.Hammer = Hammer; 3 | cornerstoneTools.external.cornerstoneMath = cornerstoneMath; 4 | 5 | cornerstoneTools.init(); 6 | 7 | const config = { 8 | maxWebWorkers: navigator.hardwareConcurrency || 1, 9 | startWebWorkersOnDemand: true, 10 | webWorkerTaskPaths: [], 11 | taskConfiguration: { 12 | decodeTask: { 13 | loadCodecsOnStartup: true, 14 | initializeCodecsOnStartup: false, 15 | strict: false 16 | } 17 | } 18 | }; 19 | 20 | cornerstoneWADOImageLoader.webWorkerManager.initialize(config); 21 | 22 | cornerstoneWADOImageLoader.external.cornerstone = cornerstone; 23 | cornerstoneWADOImageLoader.external.dicomParser = dicomParser; 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-cornerstone-viewport", 3 | "name": "react-cornerstone-viewport", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /public/script-tag-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | react-viewerbase 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import external from 'rollup-plugin-peer-deps-external'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import resolve from 'rollup-plugin-node-resolve'; 6 | import url from 'rollup-plugin-url'; 7 | import svgr from '@svgr/rollup'; 8 | 9 | import pkg from './package.json'; 10 | 11 | const globals = { 12 | react: 'React', 13 | 'react-dom': 'ReactDOM', 14 | 'cornerstone-core': 'cornerstone', 15 | 'cornerstone-math': 'cornerstoneMath', 16 | 'cornerstone-tools': 'cornerstoneTools', 17 | 'cornerstone-wado-image-loader': 'cornerstoneWADOImageLoader', 18 | 'dicom-parser': 'dicomParser', 19 | hammerjs: 'Hammer', 20 | }; 21 | 22 | export default { 23 | input: 'src/index.js', 24 | output: [ 25 | { 26 | file: pkg.main, 27 | format: 'cjs', 28 | sourcemap: true, 29 | globals, 30 | }, 31 | { 32 | file: pkg.browser, 33 | format: 'umd', 34 | name: 'react-cornerstone-viewport', 35 | sourcemap: true, 36 | globals, 37 | }, 38 | { 39 | file: pkg.module, 40 | format: 'es', 41 | sourcemap: true, 42 | globals, 43 | }, 44 | ], 45 | plugins: [ 46 | external(), 47 | postcss({ 48 | modules: false, 49 | }), 50 | url(), 51 | svgr(), 52 | babel({ 53 | exclude: 'node_modules/**', 54 | plugins: ['@babel/transform-runtime'], 55 | runtimeHelpers: true, 56 | }), 57 | resolve(), 58 | commonjs(), 59 | ], 60 | }; 61 | -------------------------------------------------------------------------------- /src/CornerstoneViewport/CornerstoneViewport.css: -------------------------------------------------------------------------------- 1 | .viewport-wrapper { 2 | width: 100%; 3 | height: 100%; /* MUST have `height` to prevent resize infinite loop */ 4 | position: relative; 5 | } 6 | 7 | .viewport-element { 8 | width: 100%; 9 | height: 100%; 10 | position: relative; 11 | background-color: black; 12 | 13 | /* Prevent the blue outline in Chrome when a viewport is selected */ 14 | outline: 0 !important; 15 | 16 | /* Prevents the entire page from getting larger 17 | when the magnify tool is near the sides/corners of the page */ 18 | overflow: hidden; 19 | } 20 | -------------------------------------------------------------------------------- /src/CornerstoneViewport/CornerstoneViewport.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import ImageScrollbar from '../ImageScrollbar/ImageScrollbar.js'; 5 | import ViewportOverlay from '../ViewportOverlay/ViewportOverlay.js'; 6 | import LoadingIndicator from '../LoadingIndicator/LoadingIndicator.js'; 7 | import ViewportOrientationMarkers from '../ViewportOrientationMarkers/ViewportOrientationMarkers.js'; 8 | import cornerstone from 'cornerstone-core'; 9 | import cornerstoneTools from 'cornerstone-tools'; 10 | import ReactResizeDetector from 'react-resize-detector'; 11 | import debounce from 'lodash.debounce'; 12 | 13 | // Util 14 | import areStringArraysEqual from './../helpers/areStringArraysEqual.js'; 15 | 16 | import './CornerstoneViewport.css'; 17 | 18 | const addToBeginning = true; 19 | const priority = -5; 20 | const requestType = 'interaction'; 21 | 22 | const scrollToIndex = cornerstoneTools.importInternal('util/scrollToIndex'); 23 | const { loadHandlerManager } = cornerstoneTools; 24 | 25 | class CornerstoneViewport extends Component { 26 | static propTypes = { 27 | imageIds: PropTypes.arrayOf(PropTypes.string).isRequired, 28 | imageIdIndex: PropTypes.number, 29 | // Controlled 30 | activeTool: PropTypes.string, 31 | tools: PropTypes.arrayOf( 32 | PropTypes.oneOfType([ 33 | // String 34 | PropTypes.string, 35 | // Object 36 | PropTypes.shape({ 37 | name: PropTypes.string, // Tool Name 38 | toolClass: PropTypes.func, // Custom (ToolClass) 39 | props: PropTypes.Object, // Props to Pass to `addTool` 40 | mode: PropTypes.string, // Initial mode, if one other than default 41 | modeOptions: PropTypes.Object, // { mouseButtonMask: [int] } 42 | }), 43 | ]) 44 | ), 45 | // Optional 46 | // isActive ?? classname -> active 47 | children: PropTypes.node, 48 | cornerstoneOptions: PropTypes.object, // cornerstone.enable options 49 | isStackPrefetchEnabled: PropTypes.bool, // should prefetch? 50 | // CINE 51 | isPlaying: PropTypes.bool, 52 | frameRate: PropTypes.number, // Between 1 and ? 53 | // 54 | initialViewport: PropTypes.object, 55 | setViewportActive: PropTypes.func, // Called when viewport should be set to active? 56 | onNewImage: PropTypes.func, 57 | onNewImageDebounced: PropTypes.func, 58 | onNewImageDebounceTime: PropTypes.number, 59 | viewportOverlayComponent: PropTypes.oneOfType([ 60 | PropTypes.string, 61 | PropTypes.func, 62 | ]), 63 | // Cornerstone Events 64 | onElementEnabled: PropTypes.func, // Escape hatch 65 | eventListeners: PropTypes.arrayOf( 66 | PropTypes.shape({ 67 | target: PropTypes.oneOf(['element', 'cornerstone']).isRequired, 68 | eventName: PropTypes.string.isRequired, 69 | handler: PropTypes.func.isRequired, 70 | }) 71 | ), 72 | startLoadHandler: PropTypes.func, 73 | endLoadHandler: PropTypes.func, 74 | loadIndicatorDelay: PropTypes.number, 75 | loadingIndicatorComponent: PropTypes.oneOfType([ 76 | PropTypes.element, 77 | PropTypes.func, 78 | ]), 79 | /** false to enable automatic viewport resizing */ 80 | enableResizeDetector: PropTypes.bool, 81 | /** rate at witch to apply resize mode's logic */ 82 | resizeRefreshRateMs: PropTypes.number, 83 | /** whether resize refresh behavior is exhibited as throttle or debounce */ 84 | resizeRefreshMode: PropTypes.oneOf(['throttle', 'debounce']), 85 | // 86 | style: PropTypes.object, 87 | className: PropTypes.string, 88 | isOverlayVisible: PropTypes.bool, 89 | orientationMarkers: PropTypes.arrayOf(PropTypes.string), 90 | }; 91 | 92 | static defaultProps = { 93 | // Watch 94 | imageIdIndex: 0, 95 | isPlaying: false, 96 | cineFrameRate: 24, 97 | viewportOverlayComponent: ViewportOverlay, 98 | imageIds: ['no-id://'], 99 | initialViewport: {}, 100 | // Init 101 | cornerstoneOptions: {}, 102 | isStackPrefetchEnabled: false, 103 | isOverlayVisible: true, 104 | loadIndicatorDelay: 45, 105 | loadingIndicatorComponent: LoadingIndicator, 106 | enableResizeDetector: true, 107 | resizeRefreshRateMs: 200, 108 | resizeRefreshMode: 'debounce', 109 | tools: [], 110 | onNewImageDebounceTime: 0, 111 | orientationMarkers: ['top', 'left'], 112 | }; 113 | 114 | constructor(props) { 115 | super(props); 116 | 117 | const imageIdIndex = props.imageIdIndex; 118 | const imageId = props.imageIds[imageIdIndex]; 119 | const isOverlayVisible = props.isOverlayVisible; 120 | 121 | this.state = { 122 | // Used for metadata lookup (imagePlane, orientation markers) 123 | // We can probs grab this once and hold on to? (updated on newImage) 124 | imageId, 125 | imageIdIndex, // Maybe 126 | imageProgress: 0, 127 | isLoading: true, 128 | error: null, 129 | // Overlay 130 | scale: undefined, 131 | windowWidth: undefined, 132 | windowCenter: undefined, 133 | isOverlayVisible, 134 | // Orientation Markers 135 | rotationDegrees: undefined, 136 | isFlippedVertically: undefined, 137 | isFlippedHorizontally: undefined, 138 | }; 139 | 140 | this._validateExternalEventsListeners(); 141 | 142 | // TODO: Deep Copy? How does that work w/ handlers? 143 | // Save a copy. Props could change before `willUnmount` 144 | this.startLoadHandler = this.props.startLoadHandler; 145 | this.endLoadHandler = this.props.endLoadHandler; 146 | this.loadHandlerTimeout = undefined; // "Loading..." timer 147 | 148 | this.numImagesLoaded = 0; 149 | } 150 | 151 | // ~~ LIFECYCLE 152 | async componentDidMount() { 153 | const { 154 | tools, 155 | isStackPrefetchEnabled, 156 | cornerstoneOptions, 157 | imageIds, 158 | isPlaying, 159 | frameRate, 160 | initialViewport, 161 | } = this.props; 162 | const { imageIdIndex } = this.state; 163 | const imageId = imageIds[imageIdIndex]; 164 | 165 | // ~~ EVENTS: CORNERSTONE 166 | this._handleOnElementEnabledEvent(); 167 | this._bindInternalCornerstoneEventListeners(); 168 | this._bindExternalEventListeners('cornerstone'); 169 | 170 | cornerstone.enable(this.element, cornerstoneOptions); 171 | 172 | // ~~ EVENTS: ELEMENT 173 | this._bindInternalElementEventListeners(); 174 | this._bindExternalEventListeners('element'); 175 | 176 | // Only after `uuid` is set for enabledElement 177 | this._setupLoadHandlers(); 178 | 179 | try { 180 | // Setup "Stack State" 181 | cornerstoneTools.clearToolState(this.element, 'stack'); 182 | cornerstoneTools.addStackStateManager(this.element, [ 183 | 'stack', 184 | 'playClip', 185 | 'referenceLines', 186 | ]); 187 | cornerstoneTools.addToolState(this.element, 'stack', { 188 | imageIds: [...imageIds], 189 | currentImageIdIndex: imageIdIndex, 190 | }); 191 | 192 | // Load first image in stack 193 | const options = { 194 | addToBeginning, 195 | priority, 196 | }; 197 | 198 | const requestFn = (imageId, options) => { 199 | return cornerstone.loadAndCacheImage(imageId, options).then((image) => { 200 | cornerstone.displayImage(this.element, image, initialViewport); 201 | }); 202 | }; 203 | 204 | // 1. Load the image using the ImageLoadingPool 205 | cornerstone.imageLoadPoolManager.addRequest( 206 | requestFn.bind(this, imageId, options), 207 | requestType, 208 | { 209 | imageId, 210 | }, 211 | priority, 212 | addToBeginning 213 | ); 214 | 215 | if (isStackPrefetchEnabled) { 216 | cornerstoneTools.stackPrefetch.enable(this.element); 217 | } 218 | 219 | if (isPlaying) { 220 | const validFrameRate = Math.max(frameRate, 1); 221 | cornerstoneTools.playClip(this.element, validFrameRate); 222 | } 223 | 224 | _addAndConfigureInitialToolsForElement(tools, this.element); 225 | _trySetActiveTool(this.element, this.props.activeTool); 226 | this.setState({ isLoading: false }); 227 | } catch (error) { 228 | this.setState({ error, isLoading: false }); 229 | } 230 | } 231 | 232 | async componentDidUpdate(prevProps, prevState) { 233 | // ~~ STACK/IMAGE 234 | const { 235 | imageIds: stack, 236 | imageIdIndex: imageIndex, 237 | isStackPrefetchEnabled, 238 | initialViewport, 239 | } = this.props; 240 | const { 241 | imageIds: prevStack, 242 | imageIdIndex: prevImageIndex, 243 | isStackPrefetchEnabled: prevIsStackPrefetchEnabled, 244 | } = prevProps; 245 | const hasStackChanged = !areStringArraysEqual(prevStack, stack); 246 | const hasImageIndexChanged = 247 | imageIndex != null && imageIndex !== prevImageIndex; 248 | let updatedState = {}; 249 | 250 | if (hasStackChanged) { 251 | // update stack toolstate 252 | cornerstoneTools.clearToolState(this.element, 'stack'); 253 | cornerstoneTools.addToolState(this.element, 'stack', { 254 | imageIds: [...stack], 255 | currentImageIdIndex: imageIndex || 0, 256 | }); 257 | 258 | // New stack; reset counter 259 | updatedState['numImagesLoaded'] = 0; 260 | updatedState['error'] = null; // Reset error on new stack 261 | 262 | try { 263 | // load + display image 264 | const imageId = stack[imageIndex || 0]; 265 | cornerstoneTools.stopClip(this.element); 266 | const requestFn = (imageId, options) => { 267 | return cornerstone 268 | .loadAndCacheImage(imageId, options) 269 | .then((image) => { 270 | cornerstone.displayImage(this.element, image, initialViewport); 271 | cornerstone.reset(this.element); 272 | }); 273 | }; 274 | 275 | cornerstone.imageLoadPoolManager.addRequest( 276 | requestFn.bind(this, imageId, { addToBeginning, priority }), 277 | requestType, 278 | { 279 | imageId, 280 | }, 281 | priority, 282 | addToBeginning 283 | ); 284 | } catch (err) { 285 | // :wave: 286 | // What if user kills component before `displayImage`? 287 | } 288 | } else if (!hasStackChanged && hasImageIndexChanged) { 289 | scrollToIndex(this.element, imageIndex); 290 | } 291 | 292 | const shouldStopStartStackPrefetch = 293 | (isStackPrefetchEnabled && hasStackChanged) || 294 | (!prevIsStackPrefetchEnabled && isStackPrefetchEnabled === true); 295 | 296 | // Need to stop/start to pickup stack changes in prefetcher 297 | if (shouldStopStartStackPrefetch) { 298 | cornerstoneTools.stackPrefetch.enable(this.element); 299 | } 300 | 301 | // ~~ ACTIVE TOOL 302 | const { activeTool } = this.props; 303 | const { activeTool: prevActiveTool } = prevProps; 304 | const hasActiveToolChanges = activeTool !== prevActiveTool; 305 | 306 | if (hasActiveToolChanges) { 307 | _trySetActiveTool(this.element, activeTool); 308 | } 309 | 310 | // ~~ CINE 311 | const { frameRate, isPlaying, isOverlayVisible } = this.props; 312 | const { 313 | frameRate: prevFrameRate, 314 | isPlaying: prevIsPlaying, 315 | isOverlayVisible: prevIsOverlayVisible, 316 | } = prevProps; 317 | const validFrameRate = Math.max(frameRate, 1); 318 | const shouldStart = 319 | (isPlaying !== prevIsPlaying && isPlaying) || 320 | (isPlaying && hasStackChanged); 321 | const shouldPause = isPlaying !== prevIsPlaying && !isPlaying; 322 | const hasFrameRateChanged = isPlaying && frameRate !== prevFrameRate; 323 | 324 | if (shouldStart || hasFrameRateChanged) { 325 | cornerstoneTools.playClip(this.element, validFrameRate); 326 | } else if (shouldPause) { 327 | cornerstoneTools.stopClip(this.element); 328 | } 329 | 330 | // ~~ OVERLAY 331 | if (isOverlayVisible !== prevIsOverlayVisible) 332 | updatedState.isOverlayVisible = isOverlayVisible; 333 | 334 | // ~~ STATE: Update aggregated state changes 335 | if (Object.keys(updatedState).length > 0) { 336 | this.setState(updatedState); 337 | } 338 | 339 | this._validateExternalEventsListeners(); 340 | } 341 | 342 | /** 343 | * Tear down any listeners/handlers, and stop any asynchronous/queued operations 344 | * that could fire after Unmount and cause errors. 345 | * 346 | * @memberof CornerstoneViewport 347 | * @returns {undefined} 348 | */ 349 | componentWillUnmount() { 350 | const clear = true; 351 | 352 | this._handleOnElementEnabledEvent(clear); 353 | this._bindInternalCornerstoneEventListeners(clear); 354 | this._bindInternalElementEventListeners(clear); 355 | this._bindExternalEventListeners('cornerstone', clear); 356 | this._bindExternalEventListeners('element', clear); 357 | this._setupLoadHandlers(clear); 358 | 359 | if (this.props.isStackPrefetchEnabled) { 360 | cornerstoneTools.stackPrefetch.disable(this.element); 361 | } 362 | 363 | cornerstoneTools.clearToolState(this.element, 'stackPrefetch'); 364 | cornerstoneTools.stopClip(this.element); 365 | cornerstone.disable(this.element); 366 | } 367 | 368 | /** 369 | * @returns Component 370 | * @memberof CornerstoneViewport 371 | */ 372 | getLoadingIndicator() { 373 | const { loadingIndicatorComponent: Component } = this.props; 374 | const { error, imageProgress } = this.state; 375 | 376 | return ; 377 | } 378 | 379 | /** 380 | * 381 | * 382 | * @returns 383 | * @memberof CornerstoneViewport 384 | */ 385 | getOverlay() { 386 | const { viewportOverlayComponent: Component, imageIds } = this.props; 387 | const { imageIdIndex, scale, windowWidth, windowCenter, isOverlayVisible } = 388 | this.state; 389 | const imageId = imageIds[imageIdIndex]; 390 | return ( 391 | imageId && 392 | windowWidth && 393 | isOverlayVisible && ( 394 | 402 | ) 403 | ); 404 | } 405 | 406 | /** 407 | * 408 | * 409 | * @returns 410 | * @memberof CornerstoneViewport 411 | */ 412 | getOrientationMarkersOverlay() { 413 | const { imageIds, orientationMarkers } = this.props; 414 | const { 415 | imageIdIndex, 416 | rotationDegrees, 417 | isFlippedVertically, 418 | isFlippedHorizontally, 419 | } = this.state; 420 | const imageId = imageIds[imageIdIndex]; 421 | 422 | // Workaround for below TODO stub 423 | if (!imageId) { 424 | return false; 425 | } 426 | // TODO: This is throwing an error with an undefined `imageId`, and it shouldn't be 427 | const { rowCosines, columnCosines } = 428 | cornerstone.metaData.get('imagePlaneModule', imageId) || {}; 429 | 430 | if (!rowCosines || !columnCosines || rotationDegrees === undefined) { 431 | return false; 432 | } 433 | 434 | return ( 435 | 443 | ); 444 | } 445 | 446 | /** 447 | * 448 | * 449 | * @param {boolean} [clear=false] - True to clear event listeners 450 | * @memberof CornerstoneViewport 451 | * @returns {undefined} 452 | */ 453 | _bindInternalCornerstoneEventListeners(clear = false) { 454 | const addOrRemoveEventListener = clear 455 | ? 'removeEventListener' 456 | : 'addEventListener'; 457 | 458 | // Update image load progress 459 | cornerstone.events[addOrRemoveEventListener]( 460 | 'cornerstoneimageloadprogress', 461 | this.onImageProgress 462 | ); 463 | 464 | // Update number of images loaded 465 | cornerstone.events[addOrRemoveEventListener]( 466 | cornerstone.EVENTS.IMAGE_LOADED, 467 | this.onImageLoaded 468 | ); 469 | } 470 | 471 | /** 472 | * 473 | * 474 | * @param {boolean} [clear=false] - True to clear event listeners 475 | * @memberof CornerstoneViewport 476 | * @returns {undefined} 477 | */ 478 | _bindInternalElementEventListeners(clear = false) { 479 | const addOrRemoveEventListener = clear 480 | ? 'removeEventListener' 481 | : 'addEventListener'; 482 | 483 | // Updates state's imageId, and imageIndex 484 | this.element[addOrRemoveEventListener]( 485 | cornerstone.EVENTS.NEW_IMAGE, 486 | this.onNewImage 487 | ); 488 | 489 | // Updates state's imageId, and imageIndex 490 | this.element[addOrRemoveEventListener]( 491 | cornerstone.EVENTS.NEW_IMAGE, 492 | this.onNewImageDebounced 493 | ); 494 | 495 | // Updates state's viewport 496 | this.element[addOrRemoveEventListener]( 497 | cornerstone.EVENTS.IMAGE_RENDERED, 498 | this.onImageRendered 499 | ); 500 | 501 | // Set Viewport Active 502 | this.element[addOrRemoveEventListener]( 503 | cornerstoneTools.EVENTS.MOUSE_CLICK, 504 | this.setViewportActive 505 | ); 506 | this.element[addOrRemoveEventListener]( 507 | cornerstoneTools.EVENTS.MOUSE_DOWN, 508 | this.setViewportActive 509 | ); 510 | this.element[addOrRemoveEventListener]( 511 | cornerstoneTools.EVENTS.TOUCH_PRESS, 512 | this.setViewportActive 513 | ); 514 | this.element[addOrRemoveEventListener]( 515 | cornerstoneTools.EVENTS.TOUCH_START, 516 | this.setViewportActive 517 | ); 518 | this.element[addOrRemoveEventListener]( 519 | cornerstoneTools.EVENTS.STACK_SCROLL, 520 | this.setViewportActive 521 | ); 522 | } 523 | 524 | /** 525 | * TODO: The ordering here will cause ELEMENT_ENABLED and ELEMENT_DISABLED 526 | * events to never fire. We should have explicit callbacks for these, 527 | * and warn appropriately if user attempts to use them with this prop. 528 | * 529 | * 530 | * Listens out for all events and then defers handling to a single listener to 531 | * act on them 532 | * 533 | * @param {string} target - "cornerstone" || "element" 534 | * @param {boolean} [clear=false] - True to clear event listeners 535 | * @returns {undefined} 536 | */ 537 | _bindExternalEventListeners(targetType, clear = false) { 538 | const addOrRemoveEventListener = clear 539 | ? 'removeEventListener' 540 | : 'addEventListener'; 541 | 542 | // Unique list of event names 543 | const cornerstoneEvents = Object.values(cornerstone.EVENTS); 544 | const cornerstoneToolsEvents = Object.values(cornerstoneTools.EVENTS); 545 | const csEventNames = cornerstoneEvents.concat(cornerstoneToolsEvents); 546 | 547 | const targetElementOrCornerstone = 548 | targetType === 'element' ? this.element : cornerstone.events; 549 | const boundMethod = this._handleExternalEventListeners.bind(this); 550 | 551 | // Bind our single handler to every cornerstone event 552 | for (let i = 0; i < csEventNames.length; i++) { 553 | targetElementOrCornerstone[addOrRemoveEventListener]( 554 | csEventNames[i], 555 | boundMethod 556 | ); 557 | } 558 | } 559 | 560 | /** 561 | * Called to validate that events passed into the event listeners prop are valid 562 | * 563 | * @returns {undefined} 564 | */ 565 | _validateExternalEventsListeners() { 566 | if (!this.props.eventListeners) return; 567 | 568 | const cornerstoneEvents = Object.values(cornerstone.EVENTS); 569 | const cornerstoneToolsEvents = Object.values(cornerstoneTools.EVENTS); 570 | 571 | for (let i = 0; i < this.props.eventListeners.length; i++) { 572 | const { 573 | target: targetType, 574 | eventName, 575 | handler, 576 | } = this.props.eventListeners[i]; 577 | if ( 578 | !cornerstoneEvents.includes(eventName) && 579 | !cornerstoneToolsEvents.includes(eventName) 580 | ) { 581 | console.warn( 582 | `No cornerstone or cornerstone-tools event exists for event name: ${eventName}` 583 | ); 584 | continue; 585 | } 586 | } 587 | } 588 | /** 589 | * Handles delegating of events from cornerstone back to the defined 590 | * external events handlers 591 | * 592 | * @param {event} 593 | * @returns {undefined} 594 | */ 595 | _handleExternalEventListeners(event) { 596 | if (!this.props.eventListeners) { 597 | return; 598 | } 599 | 600 | for (let i = 0; i < this.props.eventListeners.length; i++) { 601 | const { eventName, handler } = this.props.eventListeners[i]; 602 | 603 | if (event.type === eventName) { 604 | handler(event); 605 | } 606 | } 607 | } 608 | 609 | /** 610 | * Convenience handler to pass the "Element Enabled" event back up to the 611 | * parent via a callback. Can be used as an escape hatch for more advanced 612 | * cornerstone fucntionality. 613 | * 614 | * @memberof CornerstoneViewport 615 | * @returns {undefined} 616 | */ 617 | _handleOnElementEnabledEvent = (clear = false) => { 618 | const handler = (evt) => { 619 | const elementThatWasEnabled = evt.detail.element; 620 | if (elementThatWasEnabled === this.element) { 621 | // Pass Event 622 | this.props.onElementEnabled(evt); 623 | } 624 | }; 625 | 626 | // Start Listening 627 | if (this.props.onElementEnabled && !clear) { 628 | cornerstone.events.addEventListener( 629 | cornerstone.EVENTS.ELEMENT_ENABLED, 630 | handler 631 | ); 632 | } 633 | 634 | // Stop Listening 635 | if (clear) { 636 | cornerstone.events.removeEventListener( 637 | cornerstone.EVENTS.ELEMENT_ENABLED, 638 | handler 639 | ); 640 | } 641 | }; 642 | 643 | /** 644 | * There is a "GLOBAL/DEFAULT" load handler for start/end/error, 645 | * and one that can be defined per element. We use start/end handlers in this 646 | * component to show the "Loading..." indicator if a loading request is taking 647 | * longer than expected. 648 | * 649 | * Because we're using the "per element" handler, we need to call the user's 650 | * handler within our own (if it's set). Load Handlers are not well documented, 651 | * but you can find [their source here]{@link https://github.com/cornerstonejs/cornerstoneTools/blob/master/src/stateManagement/loadHandlerManager.js} 652 | * 653 | * @param {boolean} [clear=false] - true to remove previously set load handlers 654 | * @memberof CornerstoneViewport 655 | * @returns {undefined} 656 | */ 657 | _setupLoadHandlers(clear = false) { 658 | if (clear) { 659 | loadHandlerManager.removeHandlers(this.element); 660 | return; 661 | } 662 | 663 | // We use this to "flip" `isLoading` to true, if our startLoading request 664 | // takes longer than our "loadIndicatorDelay" 665 | const startLoadHandler = (element) => { 666 | clearTimeout(this.loadHandlerTimeout); 667 | 668 | // Call user defined loadHandler 669 | if (this.startLoadHandler) { 670 | this.startLoadHandler(element); 671 | } 672 | 673 | // We're taking too long. Indicate that we're "Loading". 674 | this.loadHandlerTimeout = setTimeout(() => { 675 | this.setState({ 676 | isLoading: true, 677 | }); 678 | }, this.props.loadIndicatorDelay); 679 | }; 680 | 681 | const endLoadHandler = (element, image) => { 682 | clearTimeout(this.loadHandlerTimeout); 683 | 684 | // Call user defined loadHandler 685 | if (this.endLoadHandler) { 686 | this.endLoadHandler(element, image); 687 | } 688 | 689 | if (this.state.isLoading) { 690 | this.setState({ 691 | isLoading: false, 692 | }); 693 | } 694 | }; 695 | 696 | loadHandlerManager.setStartLoadHandler(startLoadHandler, this.element); 697 | loadHandlerManager.setEndLoadHandler(endLoadHandler, this.element); 698 | } 699 | 700 | // TODO: May need to throttle? 701 | onImageRendered = (event) => { 702 | const viewport = event.detail.viewport; 703 | 704 | this.setState({ 705 | scale: viewport.scale, 706 | windowCenter: viewport.voi.windowCenter, 707 | windowWidth: viewport.voi.windowWidth, 708 | rotationDegrees: viewport.rotation, 709 | isFlippedVertically: viewport.vflip, 710 | isFlippedHorizontally: viewport.hflip, 711 | }); 712 | }; 713 | 714 | onNewImageHandler = (event, callback) => { 715 | const { imageId } = event.detail.image; 716 | const { sopInstanceUid } = 717 | cornerstone.metaData.get('generalImageModule', imageId) || {}; 718 | const currentImageIdIndex = this.props.imageIds.indexOf(imageId); 719 | 720 | // TODO: Should we grab and set some imageId specific metadata here? 721 | // Could prevent cornerstone dependencies in child components. 722 | this.setState({ imageIdIndex: currentImageIdIndex }); 723 | 724 | if (callback) { 725 | callback({ currentImageIdIndex, sopInstanceUid }); 726 | } 727 | }; 728 | 729 | onNewImage = (event) => this.onNewImageHandler(event, this.props.onNewImage); 730 | 731 | onNewImageDebounced = debounce((event) => { 732 | this.onNewImageHandler(event, this.props.onNewImageDebounced); 733 | }, this.props.onNewImageDebounceTime); 734 | 735 | onImageLoaded = () => { 736 | // TODO: This is not necessarily true :thinking: 737 | // We need better cache reporting a layer up 738 | this.numImagesLoaded++; 739 | }; 740 | 741 | onImageProgress = (e) => { 742 | this.setState({ 743 | imageProgress: e.detail.percentComplete, 744 | }); 745 | }; 746 | 747 | imageSliderOnInputCallback = (value) => { 748 | this.setViewportActive(); 749 | 750 | scrollToIndex(this.element, value); 751 | }; 752 | 753 | setViewportActive = () => { 754 | if (this.props.setViewportActive) { 755 | this.props.setViewportActive(); // TODO: should take viewport index/ident? 756 | } 757 | }; 758 | 759 | onResize = () => { 760 | cornerstone.resize(this.element); 761 | }; 762 | 763 | render() { 764 | const isLoading = this.state.isLoading; 765 | const displayLoadingIndicator = isLoading || this.state.error; 766 | const scrollbarMax = this.props.imageIds.length - 1; 767 | const scrollbarHeight = this.element 768 | ? `${this.element.clientHeight - 20}px` 769 | : '100px'; 770 | 771 | return ( 772 |
776 | {this.props.enableResizeDetector && this.element != null && ( 777 | 786 | )} 787 |
e.preventDefault()} 790 | onMouseDown={(e) => e.preventDefault()} 791 | ref={(input) => { 792 | this.element = input; 793 | }} 794 | > 795 | {displayLoadingIndicator && this.getLoadingIndicator()} 796 | {/* This classname is important in that it tells `cornerstone` to not 797 | * create a new canvas element when we "enable" the `viewport-element` 798 | */} 799 | 800 | {this.getOverlay()} 801 | {this.getOrientationMarkersOverlay()} 802 |
803 | 809 | {this.props.children} 810 |
811 | ); 812 | } 813 | } 814 | 815 | /** 816 | * 817 | * 818 | * @param {HTMLElement} element 819 | * @param {string} activeToolName 820 | * @returns 821 | */ 822 | function _trySetActiveTool(element, activeToolName) { 823 | if (!element || !activeToolName) { 824 | return; 825 | } 826 | 827 | const validTools = cornerstoneTools.store.state.tools.filter( 828 | (tool) => tool.element === element 829 | ); 830 | const validToolNames = validTools.map((tool) => tool.name); 831 | 832 | if (!validToolNames.includes(activeToolName)) { 833 | console.warn( 834 | `Trying to set a tool active that is not "added". Available tools include: ${validToolNames.join( 835 | ', ' 836 | )}` 837 | ); 838 | } 839 | 840 | cornerstoneTools.setToolActiveForElement(element, activeToolName, { 841 | mouseButtonMask: 1, 842 | }); 843 | } 844 | 845 | /** 846 | * Iterate over the provided tools; Add each tool to the target element 847 | * 848 | * @param {string[]|object[]} tools 849 | * @param {HTMLElement} element 850 | */ 851 | function _addAndConfigureInitialToolsForElement(tools, element) { 852 | for (let i = 0; i < tools.length; i++) { 853 | const tool = 854 | typeof tools[i] === 'string' 855 | ? { name: tools[i] } 856 | : Object.assign({}, tools[i]); 857 | const toolName = `${tool.name}Tool`; // Top level CornerstoneTools follow this pattern 858 | 859 | tool.toolClass = tool.toolClass || cornerstoneTools[toolName]; 860 | 861 | if (!tool.toolClass) { 862 | console.warn(`Unable to add tool with name '${tool.name}'.`); 863 | continue; 864 | } 865 | 866 | cornerstoneTools.addToolForElement( 867 | element, 868 | tool.toolClass, 869 | tool.props || {} 870 | ); 871 | 872 | const hasInitialMode = 873 | tool.mode && AVAILABLE_TOOL_MODES.includes(tool.mode); 874 | 875 | if (hasInitialMode) { 876 | // TODO: We may need to check `tool.props` and the tool class's prototype 877 | // to determine the name it registered with cornerstone. `tool.name` is not 878 | // reliable. 879 | const setToolModeFn = TOOL_MODE_FUNCTIONS[tool.mode]; 880 | setToolModeFn(element, tool.name, tool.modeOptions || {}); 881 | } 882 | } 883 | } 884 | 885 | const AVAILABLE_TOOL_MODES = ['active', 'passive', 'enabled', 'disabled']; 886 | 887 | const TOOL_MODE_FUNCTIONS = { 888 | active: cornerstoneTools.setToolActiveForElement, 889 | passive: cornerstoneTools.setToolPassiveForElement, 890 | enabled: cornerstoneTools.setToolEnabledForElement, 891 | disabled: cornerstoneTools.setToolDisabledForElement, 892 | }; 893 | 894 | export default CornerstoneViewport; 895 | -------------------------------------------------------------------------------- /src/ImageScrollbar/ImageScrollbar.css: -------------------------------------------------------------------------------- 1 | .scroll { 2 | height: 100%; 3 | padding: 5px; 4 | position: absolute; 5 | right: 0; 6 | top: 0; 7 | } 8 | .scroll .scroll-holder { 9 | height: calc(100% - 20px); 10 | margin-top: 5px; 11 | position: relative; 12 | width: 12px; 13 | } 14 | .scroll .scroll-holder .imageSlider { 15 | height: 12px; 16 | left: 12px; 17 | padding: 0; 18 | position: absolute; 19 | top: 0; 20 | transform: rotate(90deg); 21 | transform-origin: top left; 22 | -webkit-appearance: none; 23 | background-color: rgba(0, 0, 0, 0); 24 | } 25 | .scroll .scroll-holder .imageSlider:focus { 26 | outline: none; 27 | } 28 | .scroll .scroll-holder .imageSlider::-moz-focus-outer { 29 | border: none; 30 | } 31 | .scroll .scroll-holder .imageSlider::-webkit-slider-runnable-track { 32 | background-color: rgba(0, 0, 0, 0); 33 | border: none; 34 | cursor: pointer; 35 | height: 5px; 36 | z-index: 6; 37 | } 38 | .scroll .scroll-holder .imageSlider::-moz-range-track { 39 | background-color: rgba(0, 0, 0, 0); 40 | border: none; 41 | cursor: pointer; 42 | height: 2px; 43 | z-index: 6; 44 | } 45 | .scroll .scroll-holder .imageSlider::-ms-track { 46 | animate: 0.2s; 47 | background: transparent; 48 | border: none; 49 | border-width: 15px 0; 50 | color: rgba(0, 0, 0, 0); 51 | cursor: pointer; 52 | height: 12px; 53 | width: 100%; 54 | } 55 | .scroll .scroll-holder .imageSlider::-ms-fill-lower { 56 | background: rgba(0, 0, 0, 0); 57 | } 58 | .scroll .scroll-holder .imageSlider::-ms-fill-upper { 59 | background: rgba(0, 0, 0, 0); 60 | } 61 | .scroll .scroll-holder .imageSlider::-webkit-slider-thumb { 62 | -webkit-appearance: none !important; 63 | background-color: #163239; 64 | border: none; 65 | border-radius: 57px; 66 | cursor: -webkit-grab; 67 | height: 12px; 68 | margin-top: -4px; 69 | width: 39px; 70 | } 71 | .scroll .scroll-holder .imageSlider::-webkit-slider-thumb:active { 72 | background-color: #20a5d6; 73 | cursor: -webkit-grabbing; 74 | } 75 | .scroll .scroll-holder .imageSlider::-moz-range-thumb { 76 | background-color: #163239; 77 | border: none; 78 | border-radius: 57px; 79 | cursor: -moz-grab; 80 | height: 12px; 81 | width: 39px; 82 | z-index: 7; 83 | } 84 | .scroll .scroll-holder .imageSlider::-moz-range-thumb:active { 85 | background-color: #20a5d6; 86 | cursor: -moz-grabbing; 87 | } 88 | .scroll .scroll-holder .imageSlider::-ms-thumb { 89 | background-color: #163239; 90 | border: none; 91 | border-radius: 57px; 92 | cursor: ns-resize; 93 | height: 12px; 94 | width: 39px; 95 | } 96 | .scroll .scroll-holder .imageSlider::-ms-thumb:active { 97 | background-color: #20a5d6; 98 | } 99 | .scroll .scroll-holder .imageSlider::-ms-tooltip { 100 | display: none; 101 | } 102 | @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) { 103 | .imageSlider { 104 | left: 50px; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/ImageScrollbar/ImageScrollbar.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './ImageScrollbar.css'; 4 | 5 | class ImageScrollbar extends PureComponent { 6 | static propTypes = { 7 | value: PropTypes.number.isRequired, 8 | max: PropTypes.number.isRequired, 9 | height: PropTypes.string.isRequired, 10 | onInputCallback: PropTypes.func.isRequired, 11 | }; 12 | 13 | render() { 14 | if (this.props.max === 0) { 15 | return null; 16 | } 17 | 18 | this.style = { 19 | width: `${this.props.height}`, 20 | }; 21 | 22 | return ( 23 |
24 |
25 | 36 |
37 |
38 | ); 39 | } 40 | 41 | onChange = event => { 42 | const intValue = parseInt(event.target.value, 10); 43 | this.props.onInputCallback(intValue); 44 | }; 45 | 46 | onKeyDown = event => { 47 | // We don't allow direct keyboard up/down input on the 48 | // image sliders since the natural direction is reversed (0 is at the top) 49 | 50 | // Store the KeyCodes in an object for readability 51 | const keys = { 52 | DOWN: 40, 53 | UP: 38, 54 | }; 55 | 56 | // TODO: Enable scroll down / scroll up without depending on ohif-core 57 | if (event.which === keys.DOWN) { 58 | //OHIF.commands.run('scrollDown'); 59 | event.preventDefault(); 60 | } else if (event.which === keys.UP) { 61 | //OHIF.commands.run('scrollUp'); 62 | event.preventDefault(); 63 | } 64 | }; 65 | } 66 | 67 | export default ImageScrollbar; 68 | -------------------------------------------------------------------------------- /src/LoadingIndicator/LoadingIndicator.css: -------------------------------------------------------------------------------- 1 | .imageViewerLoadingIndicator { 2 | color: #91b9cd; 3 | } 4 | 5 | .faded { 6 | opacity: 0.5; 7 | } 8 | 9 | .imageViewerErrorLoadingIndicator { 10 | color: #e29e4a; 11 | } 12 | 13 | .imageViewerErrorLoadingIndicator p, 14 | .imageViewerErrorLoadingIndicator h4 { 15 | padding: 4px 0; 16 | text-align: center; 17 | word-wrap: break-word; 18 | } 19 | 20 | .imageViewerErrorLoadingIndicator p { 21 | font-size: 11pt; 22 | } 23 | 24 | .loadingIndicator { 25 | background-color: rgba(0, 0, 0, 0.75); 26 | font-size: 18px; 27 | height: 100%; 28 | overflow: hidden; 29 | pointer-events: none; 30 | position: absolute; 31 | top: 0; 32 | width: 100%; 33 | z-index: 1; 34 | } 35 | 36 | .loadingIndicator .indicatorContents { 37 | font-weight: 300; 38 | position: absolute; 39 | text-align: center; 40 | top: 50%; 41 | transform: translateY(-50%); 42 | width: 100%; 43 | } 44 | -------------------------------------------------------------------------------- /src/LoadingIndicator/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import './LoadingIndicator.css'; 5 | 6 | class LoadingIndicator extends PureComponent { 7 | static propTypes = { 8 | percentComplete: PropTypes.number.isRequired, 9 | error: PropTypes.object, 10 | }; 11 | 12 | static defaultProps = { 13 | percentComplete: 0, 14 | error: null, 15 | }; 16 | 17 | render() { 18 | const pc = this.props.percentComplete; 19 | const percComplete = `${pc}%`; 20 | 21 | return ( 22 | 23 | {this.props.error ? ( 24 |
25 |
26 |

Error Loading Image

27 |

An error has occurred.

28 |

{this.props.error.message}

29 |
30 |
31 | ) : ( 32 |
33 |
34 |

35 | {pc < 100 ? 'Loading...' : 'Loaded -'} 36 | {' '} 37 |

38 | {pc === 100 &&

Processing...

} 39 |
40 |
41 | )} 42 |
43 | ); 44 | } 45 | } 46 | 47 | export default LoadingIndicator; 48 | -------------------------------------------------------------------------------- /src/ViewportOrientationMarkers/ViewportOrientationMarkers.css: -------------------------------------------------------------------------------- 1 | .ViewportOrientationMarkers { 2 | --marker-width: 100px; 3 | --marker-height: 100px; 4 | --scrollbar-width: 20px; 5 | pointer-events: none; 6 | font-size: 15px; 7 | color: #ccc; 8 | line-height: 18px; 9 | } 10 | .ViewportOrientationMarkers .orientation-marker { 11 | position: absolute; 12 | } 13 | .ViewportOrientationMarkers .top-mid { 14 | top: 5px; 15 | left: 50%; 16 | } 17 | .ViewportOrientationMarkers .left-mid { 18 | top: 47%; 19 | left: 5px; 20 | } 21 | .ViewportOrientationMarkers .right-mid { 22 | top: 47%; 23 | left: calc(100% - var(--marker-width) - var(--scrollbar-width)); 24 | } 25 | .ViewportOrientationMarkers .bottom-mid { 26 | top: calc(100% - var(--marker-height) - 5px); 27 | left: 47%; 28 | } 29 | .ViewportOrientationMarkers .right-mid .orientation-marker-value { 30 | display: flex; 31 | justify-content: flex-end; 32 | min-width: var(--marker-width); 33 | } 34 | .ViewportOrientationMarkers .bottom-mid .orientation-marker-value { 35 | display: flex; 36 | justify-content: flex-start; 37 | min-height: var(--marker-height); 38 | flex-direction: column-reverse; 39 | } 40 | -------------------------------------------------------------------------------- /src/ViewportOrientationMarkers/ViewportOrientationMarkers.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import cornerstoneTools from 'cornerstone-tools'; 5 | import './ViewportOrientationMarkers.css'; 6 | 7 | /** 8 | * 9 | * Computes the orientation labels on a Cornerstone-enabled Viewport element 10 | * when the viewport settings change (e.g. when a horizontal flip or a rotation occurs) 11 | * 12 | * @param {*} rowCosines 13 | * @param {*} columnCosines 14 | * @param {*} rotationDegrees 15 | * @param {*} isFlippedVertically 16 | * @param {*} isFlippedHorizontally 17 | * @returns 18 | */ 19 | function getOrientationMarkers( 20 | rowCosines, 21 | columnCosines, 22 | rotationDegrees, 23 | isFlippedVertically, 24 | isFlippedHorizontally 25 | ) { 26 | const { 27 | getOrientationString, 28 | invertOrientationString, 29 | } = cornerstoneTools.orientation; 30 | const rowString = getOrientationString(rowCosines); 31 | const columnString = getOrientationString(columnCosines); 32 | const oppositeRowString = invertOrientationString(rowString); 33 | const oppositeColumnString = invertOrientationString(columnString); 34 | 35 | const markers = { 36 | top: oppositeColumnString, 37 | left: oppositeRowString, 38 | right: rowString, 39 | bottom: columnString, 40 | }; 41 | 42 | // If any vertical or horizontal flips are applied, change the orientation strings ahead of 43 | // the rotation applications 44 | if (isFlippedVertically) { 45 | markers.top = invertOrientationString(markers.top); 46 | markers.bottom = invertOrientationString(markers.bottom); 47 | } 48 | 49 | if (isFlippedHorizontally) { 50 | markers.left = invertOrientationString(markers.left); 51 | markers.right = invertOrientationString(markers.right); 52 | } 53 | 54 | // Swap the labels accordingly if the viewport has been rotated 55 | // This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees) 56 | if (rotationDegrees === 90 || rotationDegrees === -270) { 57 | return { 58 | top: markers.left, 59 | left: invertOrientationString(markers.top), 60 | right: invertOrientationString(markers.bottom), 61 | bottom: markers.right, // left 62 | }; 63 | } else if (rotationDegrees === -90 || rotationDegrees === 270) { 64 | return { 65 | top: invertOrientationString(markers.left), 66 | left: markers.top, 67 | bottom: markers.left, 68 | right: markers.bottom, 69 | }; 70 | } else if (rotationDegrees === 180 || rotationDegrees === -180) { 71 | return { 72 | top: invertOrientationString(markers.top), 73 | left: invertOrientationString(markers.left), 74 | bottom: invertOrientationString(markers.bottom), 75 | right: invertOrientationString(markers.right), 76 | }; 77 | } 78 | 79 | return markers; 80 | } 81 | 82 | class ViewportOrientationMarkers extends PureComponent { 83 | static propTypes = { 84 | rowCosines: PropTypes.array.isRequired, 85 | columnCosines: PropTypes.array.isRequired, 86 | rotationDegrees: PropTypes.number.isRequired, 87 | isFlippedVertically: PropTypes.bool.isRequired, 88 | isFlippedHorizontally: PropTypes.bool.isRequired, 89 | orientationMarkers: PropTypes.arrayOf(PropTypes.string), 90 | }; 91 | 92 | static defaultProps = { 93 | orientationMarkers: ['top', 'left'], 94 | }; 95 | 96 | render() { 97 | const { 98 | rowCosines, 99 | columnCosines, 100 | rotationDegrees, 101 | isFlippedVertically, 102 | isFlippedHorizontally, 103 | orientationMarkers, 104 | } = this.props; 105 | 106 | if (!rowCosines || !columnCosines) { 107 | return ''; 108 | } 109 | 110 | const markers = getOrientationMarkers( 111 | rowCosines, 112 | columnCosines, 113 | rotationDegrees, 114 | isFlippedVertically, 115 | isFlippedHorizontally 116 | ); 117 | 118 | const getMarkers = orientationMarkers => 119 | orientationMarkers.map((m, index) => ( 120 |
124 |
{markers[m]}
125 |
126 | )); 127 | 128 | return ( 129 |
130 | {getMarkers(orientationMarkers)} 131 |
132 | ); 133 | } 134 | } 135 | 136 | export default ViewportOrientationMarkers; 137 | -------------------------------------------------------------------------------- /src/ViewportOverlay/ViewportOverlay.css: -------------------------------------------------------------------------------- 1 | .imageViewerViewport.empty ~ .ViewportOverlay { 2 | display: none; 3 | } 4 | .ViewportOverlay { 5 | color: #9ccef9; 6 | } 7 | .ViewportOverlay .overlay-element { 8 | position: absolute; 9 | font-weight: 400; 10 | text-shadow: 1px 1px #000; 11 | pointer-events: none; 12 | } 13 | .ViewportOverlay .top-left { 14 | top: 20px; 15 | left: 20px; 16 | } 17 | .ViewportOverlay .top-center { 18 | top: 20px; 19 | padding-top: 20px; 20 | width: 100%; 21 | text-align: center; 22 | } 23 | .ViewportOverlay .top-right { 24 | top: 20px; 25 | right: 20px; 26 | text-align: right; 27 | } 28 | .ViewportOverlay .bottom-left { 29 | bottom: 20px; 30 | left: 20px; 31 | } 32 | .ViewportOverlay .bottom-right { 33 | bottom: 20px; 34 | right: 20px; 35 | text-align: right; 36 | } 37 | .ViewportOverlay.controlsVisible .topright, 38 | .ViewportOverlay.controlsVisible .bottomright { 39 | right: calc(20px + 19px); 40 | } 41 | .ViewportOverlay svg { 42 | color: #9ccef9; 43 | fill: #9ccef9; 44 | stroke: #9ccef9; 45 | background-color: transparent; 46 | margin: 2px; 47 | width: 18px; 48 | height: 18px; 49 | } 50 | -------------------------------------------------------------------------------- /src/ViewportOverlay/ViewportOverlay.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import cornerstone from 'cornerstone-core'; 5 | import dicomParser from 'dicom-parser'; 6 | import { helpers } from '../helpers/index.js'; 7 | import './ViewportOverlay.css'; 8 | 9 | const { 10 | formatPN, 11 | formatDA, 12 | formatNumberPrecision, 13 | formatTM, 14 | isValidNumber, 15 | } = helpers; 16 | 17 | function getCompression(imageId) { 18 | const generalImageModule = 19 | cornerstone.metaData.get('generalImageModule', imageId) || {}; 20 | const { 21 | lossyImageCompression, 22 | lossyImageCompressionRatio, 23 | lossyImageCompressionMethod, 24 | } = generalImageModule; 25 | 26 | if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') { 27 | const compressionMethod = lossyImageCompressionMethod || 'Lossy: '; 28 | const compressionRatio = formatNumberPrecision( 29 | lossyImageCompressionRatio, 30 | 2 31 | ); 32 | return compressionMethod + compressionRatio + ' : 1'; 33 | } 34 | 35 | return 'Lossless / Uncompressed'; 36 | } 37 | 38 | class ViewportOverlay extends PureComponent { 39 | static propTypes = { 40 | scale: PropTypes.number.isRequired, 41 | windowWidth: PropTypes.oneOfType([ 42 | PropTypes.number.isRequired, 43 | PropTypes.string.isRequired, 44 | ]), 45 | windowCenter: PropTypes.oneOfType([ 46 | PropTypes.number.isRequired, 47 | PropTypes.string.isRequired, 48 | ]), 49 | imageId: PropTypes.string.isRequired, 50 | imageIndex: PropTypes.number.isRequired, 51 | stackSize: PropTypes.number.isRequired, 52 | }; 53 | 54 | render() { 55 | const { imageId, scale, windowWidth, windowCenter } = this.props; 56 | 57 | if (!imageId) { 58 | return null; 59 | } 60 | 61 | const zoomPercentage = formatNumberPrecision(scale * 100, 0); 62 | const seriesMetadata = 63 | cornerstone.metaData.get('generalSeriesModule', imageId) || {}; 64 | const imagePlaneModule = 65 | cornerstone.metaData.get('imagePlaneModule', imageId) || {}; 66 | const { rows, columns, sliceThickness, sliceLocation } = imagePlaneModule; 67 | const { seriesNumber, seriesDescription } = seriesMetadata; 68 | 69 | const generalStudyModule = 70 | cornerstone.metaData.get('generalStudyModule', imageId) || {}; 71 | const { studyDate, studyTime, studyDescription } = generalStudyModule; 72 | 73 | const patientModule = 74 | cornerstone.metaData.get('patientModule', imageId) || {}; 75 | const { patientId, patientName } = patientModule; 76 | 77 | const generalImageModule = 78 | cornerstone.metaData.get('generalImageModule', imageId) || {}; 79 | const { instanceNumber } = generalImageModule; 80 | 81 | const cineModule = cornerstone.metaData.get('cineModule', imageId) || {}; 82 | const { frameTime } = cineModule; 83 | 84 | const frameRate = formatNumberPrecision(1000 / frameTime, 1); 85 | const compression = getCompression(imageId); 86 | const wwwc = `W: ${ 87 | windowWidth.toFixed ? windowWidth.toFixed(0) : windowWidth 88 | } L: ${windowWidth.toFixed ? windowCenter.toFixed(0) : windowCenter}`; 89 | const imageDimensions = `${columns} x ${rows}`; 90 | 91 | const { imageIndex, stackSize } = this.props; 92 | 93 | const normal = ( 94 | 95 |
96 |
{formatPN(patientName)}
97 |
{patientId}
98 |
99 |
100 |
{studyDescription}
101 |
102 | {formatDA(studyDate)} {formatTM(studyTime)} 103 |
104 |
105 |
106 |
Zoom: {zoomPercentage}%
107 |
{wwwc}
108 |
{compression}
109 |
110 |
111 |
{seriesNumber >= 0 ? `Ser: ${seriesNumber}` : ''}
112 |
113 | {stackSize > 1 114 | ? `Img: ${instanceNumber} ${imageIndex}/${stackSize}` 115 | : ''} 116 |
117 |
118 | {frameRate >= 0 ? `${formatNumberPrecision(frameRate, 2)} FPS` : ''} 119 |
{imageDimensions}
120 |
121 | {isValidNumber(sliceLocation) 122 | ? `Loc: ${formatNumberPrecision(sliceLocation, 2)} mm ` 123 | : ''} 124 | {sliceThickness 125 | ? `Thick: ${formatNumberPrecision(sliceThickness, 2)} mm` 126 | : ''} 127 |
128 |
{seriesDescription}
129 |
130 |
131 |
132 | ); 133 | 134 | return
{normal}
; 135 | } 136 | } 137 | 138 | export default ViewportOverlay; 139 | -------------------------------------------------------------------------------- /src/helpers/areStringArraysEqual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Compare equality of two string arrays. 3 | * 4 | * @param {string[]} [arr1] - String array #1 5 | * @param {string[]} [arr2] - String array #2 6 | * @returns {boolean} 7 | */ 8 | export default function areStringArraysEqual(arr1, arr2) { 9 | if (arr1 === arr2) return true; // Identity 10 | if (!arr1 || !arr2) return false; // One is undef/null 11 | if (arr1.length !== arr2.length) return false; // Diff length 12 | 13 | for (let i = 0; i < arr1.length; i++) { 14 | if (arr1[i] !== arr2[i]) return false; 15 | } 16 | 17 | return true; 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/formatDA.js: -------------------------------------------------------------------------------- 1 | import { parse, format } from 'date-fns'; 2 | 3 | export default function formatDA(date, strFormat = 'MMM d, yyyy') { 4 | if (!date) { 5 | return; 6 | } 7 | 8 | // Goal: 'Apr 5, 1999' 9 | try { 10 | const parsedDateTime = parse(date, 'yyyyMMdd', new Date()); 11 | const formattedDateTime = format(parsedDateTime, strFormat); 12 | 13 | return formattedDateTime; 14 | } catch (err) { 15 | // swallow? 16 | } 17 | 18 | return; 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/formatNumberPrecision.js: -------------------------------------------------------------------------------- 1 | export default function formatNumberPrecision(number, precision) { 2 | if (number !== null) { 3 | return parseFloat(number).toFixed(precision); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/formatPN.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a patient name for display purposes 3 | */ 4 | export default function formatPN(name) { 5 | if (!name) { 6 | return; 7 | } 8 | 9 | // Convert the first ^ to a ', '. String.replace() only affects 10 | // the first appearance of the character. 11 | const commaBetweenFirstAndLast = name.replace('^', ', '); 12 | 13 | // Replace any remaining '^' characters with spaces 14 | const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' '); 15 | 16 | // Trim any extraneous whitespace 17 | return cleaned.trim(); 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/formatTM.js: -------------------------------------------------------------------------------- 1 | import { parse, format } from 'date-fns'; 2 | 3 | export default function formatTM(time, strFormat = 'HH:mm:ss') { 4 | if (!time) { 5 | return; 6 | } 7 | 8 | // DICOM Time is stored as HHmmss.SSS, where: 9 | // HH 24 hour time: 10 | // m mm 0..59 Minutes 11 | // s ss 0..59 Seconds 12 | // S SS SSS 0..999 Fractional seconds 13 | // 14 | // Goal: '24:12:12' 15 | try { 16 | const inputFormat = 'HHmmss.SSS'; 17 | const strTime = time.toString().substring(0, inputFormat.length); 18 | const parsedDateTime = parse(strTime, inputFormat.substring(0,strTime.length), new Date(0)); 19 | const formattedDateTime = format(parsedDateTime, strFormat); 20 | 21 | return formattedDateTime; 22 | } catch (err) { 23 | // swallow? 24 | } 25 | 26 | return; 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | import formatPN from './formatPN'; 2 | import formatDA from './formatDA'; 3 | import formatTM from './formatTM'; 4 | import formatNumberPrecision from './formatNumberPrecision'; 5 | import isValidNumber from './isValidNumber'; 6 | 7 | const helpers = { 8 | formatPN, 9 | formatDA, 10 | formatTM, 11 | formatNumberPrecision, 12 | isValidNumber, 13 | }; 14 | 15 | export { helpers }; 16 | -------------------------------------------------------------------------------- /src/helpers/isValidNumber.js: -------------------------------------------------------------------------------- 1 | export default function isValidNumber(value) { 2 | return typeof value === 'number' && !isNaN(value); 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './metadataProvider.js'; 2 | import CornerstoneViewport from './CornerstoneViewport/CornerstoneViewport.js'; 3 | import ViewportOverlay from './ViewportOverlay/ViewportOverlay'; 4 | 5 | export { ViewportOverlay }; 6 | 7 | export default CornerstoneViewport; 8 | -------------------------------------------------------------------------------- /src/metadataProvider.js: -------------------------------------------------------------------------------- 1 | import cornerstone from 'cornerstone-core'; 2 | import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; 3 | import dicomParser from 'dicom-parser'; 4 | 5 | const { getNumberValue, getValue } = cornerstoneWADOImageLoader.wadors.metaData; 6 | 7 | function wadoRsMetaDataProvider(type, imageId) { 8 | const metaData = cornerstoneWADOImageLoader.wadors.metaDataManager.get( 9 | imageId 10 | ); 11 | 12 | if (!metaData) { 13 | return; 14 | } 15 | 16 | if ( 17 | metaData[type] !== undefined && 18 | metaData[type].Value !== undefined && 19 | metaData[type].Value.length 20 | ) { 21 | return metaData[type].Value[0]; 22 | } 23 | 24 | const typeCleaned = type.replace('x', ''); 25 | if ( 26 | metaData[typeCleaned] !== undefined && 27 | metaData[typeCleaned].Value !== undefined && 28 | metaData[typeCleaned].Value.length 29 | ) { 30 | return metaData[typeCleaned].Value[0]; 31 | } 32 | 33 | if (type === 'generalImageModule') { 34 | return { 35 | sopInstanceUid: getValue(metaData['00080018']), 36 | instanceNumber: getNumberValue(metaData['00200013']), 37 | lossyImageCompression: getValue(metaData['00282110']), 38 | lossyImageCompressionRatio: getValue(metaData['00282112']), 39 | lossyImageCompressionMethod: getValue(metaData['00282114']), 40 | }; 41 | } 42 | 43 | if (type === 'patientModule') { 44 | return { 45 | patientName: getValue(metaData['00100010']), 46 | patientId: getValue(metaData['00100020']), 47 | patientSex: getValue(metaData['00100040']), 48 | patientBirthDate: getValue(metaData['00100030']), 49 | }; 50 | } 51 | 52 | if (type === 'spacingBetweenSlices') { 53 | return getValue(metaData['00180088']); 54 | } 55 | 56 | if (type === 'generalStudyModule') { 57 | return { 58 | studyDescription: getValue(metaData['00081030']), 59 | studyDate: getValue(metaData['00080020']), 60 | studyTime: getValue(metaData['00080030']), 61 | accessionNumber: getValue(metaData['00080050']), 62 | }; 63 | } 64 | 65 | if (type === 'cineModule') { 66 | return { 67 | frameTime: getNumberValue(metaData['00181063']), 68 | }; 69 | } 70 | } 71 | 72 | cornerstone.metaData.addProvider(wadoRsMetaDataProvider); 73 | 74 | function wadoUriMetaDataProvider(type, imageId) { 75 | const { 76 | parseImageId, 77 | dataSetCacheManager, 78 | } = cornerstoneWADOImageLoader.wadouri; 79 | const parsedImageId = parseImageId(imageId); 80 | const dataSet = dataSetCacheManager.get(parsedImageId.url); 81 | 82 | if (!dataSet) { 83 | return; 84 | } 85 | 86 | if (type === 'generalImageModule') { 87 | return { 88 | sopInstanceUid: dataSet.string('x00080018'), 89 | instanceNumber: dataSet.intString('x00200013'), 90 | lossyImageCompression: dataSet.string('x00282110'), 91 | lossyImageCompressionRatio: dataSet.string('x00282112'), 92 | lossyImageCompressionMethod: dataSet.string('x00282114'), 93 | }; 94 | } 95 | 96 | if (type === 'patientModule') { 97 | return { 98 | patientName: dataSet.string('x00100010'), 99 | patientId: dataSet.string('x00100020'), 100 | }; 101 | } 102 | 103 | if (type === 'generalStudyModule') { 104 | return { 105 | studyDescription: dataSet.string('x00081030'), 106 | studyDate: dataSet.string('x00080020'), 107 | studyTime: dataSet.string('x00080030'), 108 | }; 109 | } 110 | 111 | if (type === 'cineModule') { 112 | return { 113 | frameTime: dataSet.floatString('x00181063'), 114 | }; 115 | } 116 | 117 | if (dataSet.elements[type] !== undefined) { 118 | const element = dataSet.elements[type]; 119 | if (!element.vr) { 120 | return; 121 | } 122 | 123 | return dicomParser.explicitElementToString(dataSet, element); 124 | } 125 | } 126 | 127 | cornerstone.metaData.addProvider(wadoUriMetaDataProvider); 128 | -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import CornerstoneViewport from './CornerstoneViewport/CornerstoneViewport.js'; 2 | 3 | describe('CornerstoneViewport', () => { 4 | it('is truthy', () => { 5 | expect(CornerstoneViewport).toBeTruthy(); 6 | }); 7 | }); 8 | --------------------------------------------------------------------------------