Chore/kill yalc with fire (#629)

* init

* more shifting

* Move @babel dependencies up to workspace root

* Begin updating dependent libraries to use sync'd webpack builds w/ watches

* Commit changes before a long weekend

* Split packages into platform and extensions

* Consolidate how/where we specify file/module type entrypoints

* Make it possible to pass in extensions as App props

* Resolve viewer's modules

* Target for UMD bundle

* Add Webpack Stylus loader

* Provide WebPack build options for microscopy, vtk, and ui

* Better entrypoint for extensions

* Push changes up to switch PCs

* Fix ESM symlink build for Viewers

* Shift docs to root

* Specify additional lerna config props

* Begin providing basic readme info

* Rename example extension folder

* Shift links to bottom of doc

* First attempt at a netlify deploy preview

* Attempt to fix deploy preview build

* push workspace enabled to initial command; remove second yarn install; use npx to call gitbook cli commands

* Try to use global gitbook-cli

* Export node_modules path

* More agressive with modifying PATH

* Follow deploy log output advice

* try again to set bin path

* Try swapping bash with a new shell

* Try bash -l instead of exec bash

* Annd let's try again

* and again

* We may have figured it out johnny, boy

* We need to pull cornerstone-wado-image-loader files from hoisted node_modules

* Attempt to fix ticks/escapes

* Change copy syntax; try running tests on viewers from root for circleci

* Lower version to match circleci image

* Try alternative jest-canvas-mock location and version file syntax

* Run version command instead of calling node directly

* Try alternative jest transform

* Set path and enable workspaces

* Add codecov flags

* Use cpx so our copying finishes?

* Get all platform unit tests to run

* Run and report individually and in parallel

* Try once relying on codecov yaml to split w/ flags

* Add projects to split by flags

* Generate separate reports

* Trigger codecov after everything has finished running; these may not support flags

* Use recommended flags from issue comments for codecov

* Fix typo

* Remove clear flag

* Shift back to calling codecov from root

* Generate example for codecov issue

* Try fixing paths

* Simplify

* Try using codecov orb

* Bump circleci version

* Upload core and viewer

* Use full string paths

* Remove individual codecov calls

* Fix dir

* Combine files before upload

* Escape anchors

* tryfix syntax

* Escape string literal

* Combine lines to reduce path

* long paths

* Use home alias

* Fix filename

* Try to see the contents of our cat'd file

* Also upload `core`

* Try running with aliased folder

* Fix path; split PR and Merge unit tests into separate jobs

* Set default threshold

* Fix typo

* Changing to scoped package names

* Adding note

* Catch more updates

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.0
 - @ohif/extension-dicom-html@0.0.4-alpha.0
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.0
 - @ohif/extension-dicom-pdf@0.0.8-alpha.0
 - @ohif/extension-vtk@0.1.4-alpha.0
 - @ohif/core@0.11.1-alpha.0
 - @ohif/i18n@0.2.3-alpha.0
 - @ohif/ui@0.2.18-alpha.0
 - @ohif/viewer@0.0.22-alpha.0

* Clean up primary readme

* Support for dev and dev:* commands

* Shift build command; satisfy default PWA build

* Remove old scripts

* Clean up UI to set webpack scripts

* Tidy up project links

* Clean duplicate code in UI project

* Update UI project's docs

* Clean up ui and i18n config

* Reduce duplicate code in `@ohif/core`

* Fix @ohif/ui versions

* bump @ohif/core versions

* Redux testkit dep

* Remove unused/duplicate config files for projects/packages

* ohif-core --> @ohif/core

* Safer publish command

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.1
 - @ohif/extension-dicom-html@0.0.4-alpha.1
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.1
 - @ohif/extension-dicom-pdf@0.0.8-alpha.1
 - @ohif/extension-vtk@0.1.4-alpha.1
 - @ohif/core@0.11.1-alpha.1
 - @ohif/i18n@0.2.3-alpha.1
 - @ohif/ui@0.2.18-alpha.1
 - @ohif/viewer@0.0.22-alpha.1

* test version bump

* Add global prettier config

* chore(release): publish %s [skip ci]

 - @ohif/viewer@0.0.22-alpha.2

* test lerna publish

* Update package.jsons

* Fix build scripts

* catch changes

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.2
 - @ohif/extension-dicom-html@0.0.4-alpha.2
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.2
 - @ohif/extension-dicom-pdf@0.0.8-alpha.2
 - @ohif/extension-vtk@0.1.4-alpha.2
 - @ohif/core@0.11.1-alpha.2
 - @ohif/i18n@0.2.3-alpha.2
 - @ohif/ui@0.2.18-alpha.2
 - @ohif/viewer@0.0.22-alpha.3

* fix eslint issues caused by eslint 6 and our react-app config

* Publish with canary tag

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.3
 - @ohif/extension-dicom-html@0.0.4-alpha.3
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.3
 - @ohif/extension-dicom-pdf@0.0.8-alpha.3
 - @ohif/extension-vtk@0.1.4-alpha.3
 - @ohif/core@0.11.1-alpha.3
 - @ohif/i18n@0.2.3-alpha.3
 - @ohif/ui@0.2.18-alpha.3
 - @ohif/viewer@0.0.22-alpha.4

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.4
 - @ohif/extension-dicom-html@0.0.4-alpha.4
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.4
 - @ohif/extension-dicom-pdf@0.0.8-alpha.4
 - @ohif/extension-vtk@0.1.4-alpha.4
 - @ohif/core@0.11.1-alpha.4
 - @ohif/i18n@0.2.3-alpha.4
 - @ohif/ui@0.2.18-alpha.4
 - @ohif/viewer@0.0.22-alpha.5

* Simplify build

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.5
 - @ohif/extension-dicom-html@0.0.4-alpha.5
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.5
 - @ohif/extension-dicom-pdf@0.0.8-alpha.5
 - @ohif/extension-vtk@0.1.4-alpha.5
 - @ohif/core@0.11.1-alpha.5
 - @ohif/i18n@0.2.3-alpha.5
 - @ohif/ui@0.2.18-alpha.5
 - @ohif/viewer@0.0.22-alpha.6

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.6
 - @ohif/extension-dicom-html@0.0.4-alpha.6
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.6
 - @ohif/extension-dicom-pdf@0.0.8-alpha.6
 - @ohif/extension-vtk@0.1.4-alpha.6
 - @ohif/core@0.11.1-alpha.6
 - @ohif/i18n@0.2.3-alpha.6
 - @ohif/ui@0.2.18-alpha.6
 - @ohif/viewer@0.0.22-alpha.7

* Split version and publish

* chore(release): publish %s [skip ci]

 - @ohif/extension-cornerstone@0.0.39-alpha.7
 - @ohif/extension-dicom-html@0.0.4-alpha.7
 - @ohif/extension-dicom-microscopy@0.0.9-alpha.7
 - @ohif/extension-dicom-pdf@0.0.8-alpha.7
 - @ohif/extension-vtk@0.1.4-alpha.7
 - @ohif/core@0.11.1-alpha.7
 - @ohif/i18n@0.2.3-alpha.7
 - @ohif/ui@0.2.18-alpha.7
 - @ohif/viewer@0.0.22-alpha.8

* Try simplify netlify build

* react-viewerbase --> @ohif/ui

* add test for stylus loader

* Try new way to specify publish path

* Use an output folder that won't be ignored

* Try move command

* fiddle with paths

* Dig for some information

* Make sure we're in root for our commands

* Remove discovery

* Setup script-tag example

* Fix typo

* Move config folder

* Update cornerstone extension to use shared/root webpack configs

* capture changes

* Update extension webpack configs

* Don't check in docz output

* Fix babel runtime resolution issue

* Simplify ui build steps

* Push in progress

* Shift directories

* Get viewer dev path to work

* Fix viewer commonjs build

* Fix build pwa config for viewers

* Make sure our package specific command can be called from root

* Update notes

* Fix imports and misc. merge notes

* Bump wado-image-loader version

* Switch up commands to remove webworkers copy

* Fix CI config

* Fix more broken package references

* Add react-dropzone

* Persist so e2e test can use

* Fix lockfile

* Use cypress orb to simplify CI

* Fix config

* Try again

* Install yarn latest for orb

* Install yarn a different way

* Wait on correct port

* Run tests in viewer project

* Fix cypress config

* Make Cypress happier - use default location

* Simplify e2e testing

* Capture UI changes since fork

* Catch core up

* Grab latest deps after merge

* Fix broken import

* Make sure cornerstone-wado-image-loader resolves to 3+

* Note regarding orbs

* Fix refresh on 404 routes

* Fix odd error for cornerstone-wado-image-loader

* Different target for UMD

* Simplify dev script

* Try to get fancy with netlify output

* Let's do it again

* Try to fix measurementTools issue

* See if we can get our lerna magic to work on merge to monorepo branch
This commit is contained in:
Danny Brown authored and GitHub committed 2019-08-13 15:36:46 -04:00
1 parent cac911f08e
commit 08ff214ea6
919 files changed
+45652 -52097

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import './config';
// Polyfills
// PWA Only?
import 'core-js/features/array/flat';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import {
CommandsManager,
ExtensionManager,
HotkeysManager,
utils,
} from '@ohif/core';
import React, { Component } from 'react';
import {
getUserManagerForOpenIdConnectClient,
initWebWorkers,
} from './utils/index.js';
import { I18nextProvider } from 'react-i18next';
import initCornerstoneTools from './initCornerstoneTools.js';
// ~~ EXTENSIONS
import { GenericViewerCommands, MeasurementsPanel } from './appExtensions';
import OHIFCornerstoneExtension from '@ohif/extension-cornerstone';
import OHIFStandaloneViewer from './OHIFStandaloneViewer';
import { OidcProvider } from 'redux-oidc';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import WhiteLabellingContext from './WhiteLabellingContext';
import { getActiveContexts } from './store/layout/selectors.js';
import i18n from '@ohif/i18n';
import setupTools from './setupTools.js';
import store from './store';
import UserManagerContext from './UserManagerContext';
// ~~~~ APP SETUP
initCornerstoneTools({
globalToolSyncEnabled: true,
showSVGCursors: true,
});
const commandsManagerConfig = {
getAppState: () => store.getState(),
getActiveContexts: () => getActiveContexts(store.getState()),
};
const commandsManager = new CommandsManager(commandsManagerConfig);
const hotkeysManager = new HotkeysManager(commandsManager);
const extensionManager = new ExtensionManager({ commandsManager });
// CornerstoneTools and labeling/measurements?
setupTools(store);
// ~~~~ END APP SETUP
// TODO[react] Use a provider when the whole tree is React
window.store = store;
class App extends Component {
static propTypes = {
routerBasename: PropTypes.string.isRequired,
servers: PropTypes.object.isRequired,
//
oidc: PropTypes.array,
whiteLabelling: PropTypes.object,
extensions: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
})
),
};
static defaultProps = {
whiteLabelling: {},
oidc: [],
extensions: [],
};
constructor(props) {
super(props);
if (this.props.oidc.length) {
const firstOpenIdClient = this.props.oidc[0];
const { protocol, host } = window.location;
const { routerBasename } = this.props;
const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
const silent_redirect_uri =
firstOpenIdClient.silent_redirect_uri || '/silent-refresh.html';
const post_logout_redirect_uri =
firstOpenIdClient.post_logout_redirect_uri || '/';
const openIdConnectConfiguration = Object.assign({}, firstOpenIdClient, {
redirect_uri: _makeAbsoluteIfNecessary(redirect_uri, baseUri),
silent_redirect_uri: _makeAbsoluteIfNecessary(
silent_redirect_uri,
baseUri
),
post_logout_redirect_uri: _makeAbsoluteIfNecessary(
post_logout_redirect_uri,
baseUri
),
});
this.userManager = getUserManagerForOpenIdConnectClient(
store,
openIdConnectConfiguration
);
}
_initExtensions(this.props.extensions);
_initServers(this.props.servers);
initWebWorkers();
}
render() {
const userManager = this.userManager;
if (userManager) {
return (
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<OidcProvider store={store} userManager={userManager}>
<UserManagerContext.Provider value={userManager}>
<Router basename={this.props.routerBasename}>
<WhiteLabellingContext.Provider
value={this.props.whiteLabelling}
>
<OHIFStandaloneViewer userManager={userManager} />
</WhiteLabellingContext.Provider>
</Router>
</UserManagerContext.Provider>
</OidcProvider>
</I18nextProvider>
</Provider>
);
}
return (
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<Router basename={this.props.routerBasename}>
<WhiteLabellingContext.Provider value={this.props.whiteLabelling}>
<OHIFStandaloneViewer />
</WhiteLabellingContext.Provider>
</Router>
</I18nextProvider>
</Provider>
);
}
}
/**
* @param
*/
function _initExtensions(extensions) {
const defaultExtensions = [
GenericViewerCommands,
MeasurementsPanel,
OHIFCornerstoneExtension,
];
const mergedExtensions = defaultExtensions.concat(extensions);
extensionManager.registerExtensions(mergedExtensions);
// Must run after extension commands are registered
if (window.config.hotkeys) {
hotkeysManager.setHotkeys(window.config.hotkeys, true);
}
}
function _initServers(servers) {
if (servers) {
utils.addServers(servers, store);
}
}
function _isAbsoluteUrl(url) {
return url.includes('http://') || url.includes('https://');
}
function _makeAbsoluteIfNecessary(url, base_url) {
if (_isAbsoluteUrl(url)) {
return url;
}
// Make sure base_url and url are not duplicating slashes
if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1);
}
return base_url + url;
}
export default App;
export { commandsManager, extensionManager, hotkeysManager };
@@ -0,0 +1,37 @@
html,
body,
#root {
background-color: black;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: Roboto, OpenSans, HelveticaNeue-Light, Helvetica Neue Light,
Helvetica Neue, Helvetica, Arial, Lucida Grande, sans-serif;
}
body {
font-size: 14px;
line-height: 1.42857143;
}
#root .fade-enter {
opacity: 0;
}
#root .fade-enter-active {
opacity: 1;
transition: opacity 200ms;
}
#root .fade-exit {
opacity: 1;
}
#root .fade-exit-active {
opacity: 0;
transition: opacity 200ms;
}
+223
View File
@@ -0,0 +1,223 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withRouter, matchPath } from 'react-router';
import { Route, Switch } from 'react-router-dom';
import { NProgress } from '@tanem/react-nprogress';
import { CSSTransition } from 'react-transition-group';
import { connect } from 'react-redux';
import { ViewerbaseDragDropContext } from '@ohif/ui';
import { SignoutCallbackComponent } from 'redux-oidc';
// import asyncComponent from './components/AsyncComponent.js'
import IHEInvokeImageDisplay from './routes/IHEInvokeImageDisplay.js';
import ViewerRouting from './routes/ViewerRouting.js';
import ViewerLocalFileData from './connectedComponents/ViewerLocalFileData.js';
import StudyListRouting from './studylist/StudyListRouting.js';
import StandaloneRouting from './routes/StandaloneRouting.js';
import CallbackPage from './routes/CallbackPage.js';
import NotFound from './routes/NotFound.js';
import { Bar, Container } from './components/LoadingBar/';
import './OHIFStandaloneViewer.css';
import './variables.css';
import './theme-tide.css';
// Dynamic Import Routes (CodeSplitting)
// const IHEInvokeImageDisplay = asyncComponent(() =>
// import('./routes/IHEInvokeImageDisplay.js')
// )
// const ViewerRouting = asyncComponent(() => import('./routes/ViewerRouting.js'))
// const StudyListRouting = asyncComponent(() =>
// import('./studylist/StudyListRouting.js')
// )
// const StandaloneRouting = asyncComponent(() =>
// import('./routes/StandaloneRouting.js')
// )
// const CallbackPage = asyncComponent(() => import('./CallbackPage.js'))
//
const reload = () => window.location.reload();
class OHIFStandaloneViewer extends Component {
state = {
isLoading: false,
};
static propTypes = {
history: PropTypes.object.isRequired,
user: PropTypes.object,
setContext: PropTypes.func,
userManager: PropTypes.object,
location: PropTypes.object,
};
componentDidMount() {
this.unlisten = this.props.history.listen((location, action) => {
if (this.props.setContext) {
this.props.setContext(window.location.pathname);
}
});
}
componentWillUnmount() {
this.unlisten();
}
render() {
const { user, userManager } = this.props;
const userNotLoggedIn = userManager && (!user || user.expired);
if (userNotLoggedIn) {
const pathname = this.props.location.pathname;
if (pathname !== '/callback') {
sessionStorage.setItem('ohif-redirect-to', pathname);
}
return (
<Switch>
<Route exact path="/silent-refresh.html" onEnter={reload} />
<Route
exact
path="/logout-redirect"
render={() => (
<SignoutCallbackComponent
userManager={userManager}
successCallback={() => console.log('Signout successful')}
errorCallback={error => {
console.warn(error);
console.warn('Signout failed');
}}
/>
)}
/>
<Route
path="/callback"
render={() => <CallbackPage userManager={userManager} />}
/>
<Route
component={() => {
userManager.getUser().then(user => {
if (user) {
userManager.signinSilent();
} else {
userManager.signinRedirect();
}
});
return null;
}}
/>
</Switch>
);
}
/**
* Note: this approach for routing is caused by the conflict between
* react-transition-group and react-router's <Switch> component.
*
* See http://reactcommunity.org/react-transition-group/with-react-router/
*/
const routes = [
{
path: '/local',
Component: ViewerLocalFileData,
},
{
path: '/viewer',
Component: StandaloneRouting,
},
{
path: '/viewer/:studyInstanceUids',
Component: ViewerRouting,
},
{
path: '/study/:studyInstanceUids/series/:seriesInstanceUids',
Component: ViewerRouting,
},
{
path: '/IHEInvokeImageDisplay',
Component: IHEInvokeImageDisplay,
},
];
const showStudyList =
window.config && window.config.showStudyList !== undefined
? window.config.showStudyList
: true;
if (showStudyList) {
routes.push({
path: '/studylist',
Component: StudyListRouting,
});
routes.push({
path: '/',
Component: StudyListRouting,
});
}
const currentPath = this.props.location.pathname;
const noMatchingRoutes = !routes.find(r =>
matchPath(currentPath, {
path: r.path,
exact: true,
})
);
return (
<>
<NProgress isAnimating={this.state.isLoading}>
{({ isFinished, progress, animationDuration }) => (
<Container
isFinished={isFinished}
animationDuration={animationDuration}
>
<Bar progress={progress} animationDuration={animationDuration} />
</Container>
)}
</NProgress>
<Route exact path="/silent-refresh.html" onEnter={reload} />
<Route exact path="/logout-redirect.html" onEnter={reload} />
{!noMatchingRoutes &&
routes.map(({ path, Component }) => (
<Route key={path} exact path={path}>
{({ match }) => (
<CSSTransition
in={match !== null}
timeout={300}
classNames="fade"
unmountOnExit
onEnter={() => {
this.setState({ isLoading: true });
}}
onEntered={() => {
this.setState({ isLoading: false });
}}
>
{match === null ? (
<></>
) : (
<Component match={match} location={this.props.location} />
)}
</CSSTransition>
)}
</Route>
))}
{noMatchingRoutes && <NotFound />}
</>
);
}
}
const mapStateToProps = state => {
return {
user: state.oidc.user,
};
};
const ConnectedOHIFStandaloneViewer = connect(
mapStateToProps,
null
)(OHIFStandaloneViewer);
export default ViewerbaseDragDropContext(
withRouter(ConnectedOHIFStandaloneViewer)
);
@@ -0,0 +1,5 @@
import React from 'react';
const UserManagerContext = React.createContext();
export default UserManagerContext;
@@ -0,0 +1,10 @@
import OHIFLogo from './components/OHIFLogo/OHIFLogo.js';
import React from 'react';
const defaultContextValues = {
logoComponent: OHIFLogo(),
};
const WhiteLabellingContext = React.createContext(defaultContextValues);
export default WhiteLabellingContext;
@@ -0,0 +1,5 @@
export default {
webWorkerManager: {
initialize: jest.fn(),
},
};
@@ -0,0 +1,3 @@
// https://jestjs.io/docs/en/webpack#handling-static-assets
module.exports = 'test-file-stub';
@@ -0,0 +1,4 @@
const createUserManager = () => jest.fn().mockReturnValue({});
const loadUser = () => jest.fn();
export { createUserManager, loadUser };
@@ -0,0 +1,9 @@
const _ = require('lodash');
const originalConsoleError = console.error;
// JSDom's CSS Parser has limited support for certain features
// This supresses error warnings caused by it
console.error = function(msg) {
if (_.startsWith(msg, 'Error: Could not parse CSS stylesheet')) return;
originalConsoleError(msg);
};
@@ -0,0 +1,39 @@
import { redux } from "@ohif/core";
import store from "./../../store";
const { setViewportActive } = redux.actions;
const actions = {
updateViewportDisplaySet: ({ direction }) => {
// TODO
// console.warn('updateDisplaySet: ', direction);
},
updateActiveViewport: ({ viewports, direction }) => {
const { viewportSpecificData, activeViewportIndex } = viewports;
const maxIndex = Object.keys(viewportSpecificData).length - 1;
let newIndex = activeViewportIndex + direction;
newIndex = newIndex > maxIndex ? 0 : newIndex;
newIndex = newIndex < 0 ? maxIndex : newIndex;
store.dispatch(setViewportActive(newIndex));
}
};
const definitions = {
// Next/Previous active viewport
incrementActiveViewport: {
commandFn: actions.updateActiveViewport,
storeContexts: ["viewports"],
options: { direction: 1 }
},
decrementActiveViewport: {
commandFn: actions.updateActiveViewport,
storeContexts: ["viewports"],
options: { direction: -1 }
}
};
export default {
definitions,
defaultContext: "VIEWER"
};
@@ -0,0 +1,8 @@
import commandsModule from './commandsModule.js';
export default {
id: 'generic-viewer-commands',
getCommandsModule() {
return commandsModule;
},
};
@@ -0,0 +1,356 @@
import { connect } from 'react-redux';
import { MeasurementTable } from '@ohif/ui';
import OHIF from '@ohif/core';
import moment from 'moment';
import cornerstone from 'cornerstone-core';
//
import jumpToRowItem from './jumpToRowItem.js';
import getMeasurementLocationCallback from './../../lib/getMeasurementLocationCallback';
const { setViewportSpecificData } = OHIF.redux.actions;
const { MeasurementApi } = OHIF.measurements;
function groupBy(list, props) {
return list.reduce((a, b) => {
(a[b[props]] = a[b[props]] || []).push(b);
return a;
}, {});
}
function getAllTools() {
const config = OHIF.measurements.MeasurementApi.getConfiguration();
let tools = [];
config.measurementTools.forEach(
toolGroup => (tools = tools.concat(toolGroup.childTools))
);
return tools;
}
function getMeasurementText(measurementData) {
const { location, description } = measurementData;
let text = '...';
if (location) {
text = location;
if (description) {
text += `(${description})`;
}
}
return text;
}
function getDataForEachMeasurementNumber(
measurementNumberList,
timepoints,
displayFunction
) {
const data = [];
// on each measurement number we should get each measurement data by available timepoint
measurementNumberList.forEach(measurement => {
timepoints.forEach(timepoint => {
const eachData = {
displayText: '...',
};
if (measurement.timepointId === timepoint.timepointId) {
eachData.displayText = displayFunction(measurement);
}
data.push(eachData);
});
});
return data;
}
function convertMeasurementsToTableData(toolCollections, timepoints) {
const config = OHIF.measurements.MeasurementApi.getConfiguration();
const toolGroups = config.measurementTools;
const tools = getAllTools();
const tableMeasurements = toolGroups.map(toolGroup => {
return {
groupName: toolGroup.name,
groupId: toolGroup.id,
measurements: [],
};
});
Object.keys(toolCollections).forEach(toolId => {
const toolMeasurements = toolCollections[toolId];
const tool = tools.find(tool => tool.id === toolId);
const { displayFunction } = tool.options.measurementTable;
// Group by measurementNumber so we can display then all in the same line
const groupedMeasurements = groupBy(toolMeasurements, 'measurementNumber');
Object.keys(groupedMeasurements).forEach(groupedMeasurementsIndex => {
const measurementNumberList =
groupedMeasurements[groupedMeasurementsIndex];
const measurementData = measurementNumberList[0];
const {
measurementNumber,
lesionNamingNumber,
toolType,
} = measurementData;
const measurementId = measurementData._id;
//check if all measurements with same measurementNumber will have same LABEL
const tableMeasurement = {
itemNumber: lesionNamingNumber,
label: getMeasurementText(measurementData),
measurementId,
measurementNumber,
lesionNamingNumber,
toolType,
hasWarnings: false, //TODO
warningTitle: '', //TODO
isSplitLesion: false, //TODO
warningList: [], //TODO
data: getDataForEachMeasurementNumber(
measurementNumberList,
timepoints,
displayFunction
),
};
// find the group object for the tool
const toolGroupMeasurements = tableMeasurements.find(group => {
return group.groupId === tool.toolGroup;
});
// inject the new measurement for this measurementNumer
toolGroupMeasurements.measurements.push(tableMeasurement);
});
});
// Sort measurements by lesion naming number
tableMeasurements.forEach(tm => {
tm.measurements.sort((m1, m2) =>
m1.lesionNamingNumber > m2.lesionNamingNumber ? 1 : -1
);
});
return tableMeasurements;
}
function convertTimepointsToTableData(timepoints) {
if (!timepoints || !timepoints.length) {
return [];
}
return [
{
label: 'Study date:',
key: 'StudyDate',
date: moment(timepoints[0].latestDate).format('DD-MMM-YY'),
},
];
}
const mapStateToProps = state => {
const { timepoints, measurements } = state.timepointManager;
return {
timepoints: convertTimepointsToTableData(timepoints),
measurementCollection: convertMeasurementsToTableData(
measurements,
timepoints
),
timepointManager: state.timepointManager,
viewports: state.viewports,
};
};
const mapDispatchToProps = dispatch => {
return {
dispatchRelabel: (event, measurementData, viewportsState) => {
const activeViewportIndex =
(viewportsState && viewportsState.activeViewportIndex) || 0;
const enabledElements = cornerstone.getEnabledElements();
if (!enabledElements || enabledElements.length <= activeViewportIndex) {
OHIF.log.error('Failed to find the enabled element');
return;
}
const { element } = enabledElements[activeViewportIndex];
const eventData = {
event: {
clientX: event.clientX,
clientY: event.clientY,
},
element,
};
const { toolType, measurementId } = measurementData;
const tool = MeasurementApi.Instance.tools[toolType].find(measurement => {
return measurement._id === measurementId;
});
const options = {
skipAddLabelButton: true,
editLocation: true,
};
// Clone the tool not to set empty location initially
const toolForLocation = Object.assign({}, tool, { location: null });
getMeasurementLocationCallback(eventData, toolForLocation, options);
},
dispatchEditDescription: (event, measurementData, viewportsState) => {
const activeViewportIndex =
(viewportsState && viewportsState.activeViewportIndex) || 0;
const enabledElements = cornerstone.getEnabledElements();
if (!enabledElements || enabledElements.length <= activeViewportIndex) {
OHIF.log.error('Failed to find the enabled element');
return;
}
const { element } = enabledElements[activeViewportIndex];
const eventData = {
event: {
clientX: event.clientX,
clientY: event.clientY,
},
element,
};
const { toolType, measurementId } = measurementData;
const tool = MeasurementApi.Instance.tools[toolType].find(measurement => {
return measurement._id === measurementId;
});
const options = {
editDescriptionOnDialog: true,
};
getMeasurementLocationCallback(eventData, tool, options);
},
dispatchJumpToRowItem: (
measurementData,
viewportsState,
timepointManagerState,
options
) => {
const actionData = jumpToRowItem(
measurementData,
viewportsState,
timepointManagerState,
dispatch,
options
);
actionData.viewportSpecificData.forEach(viewportSpecificData => {
const { viewportIndex, displaySet } = viewportSpecificData;
dispatch(setViewportSpecificData(viewportIndex, displaySet));
});
const { toolType, measurementNumber } = measurementData;
const measurementApi = MeasurementApi.Instance;
Object.keys(measurementApi.tools).forEach(toolType => {
const measurements = measurementApi.tools[toolType];
measurements.forEach(measurement => {
measurement.active = false;
});
});
const measurementsToActive = measurementApi.tools[toolType].filter(
measurement => {
return measurement.measurementNumber === measurementNumber;
}
);
measurementsToActive.forEach(measurementToActive => {
measurementToActive.active = true;
});
measurementApi.syncMeasurementsAndToolData();
cornerstone.getEnabledElements().forEach(enabledElement => {
cornerstone.updateImage(enabledElement.element);
});
// Needs to update viewports.layout state to set layout
//const layout = actionData.layout;
//dispatch(setLayout(layout))
// Needs to update viewports.activeViewportIndex to the first updated viewport
//const viewportIndex = actionData.viewportIndex;
//dispatch(setViewportActive(viewportIndex));
// Needs to update timepointsManager.measurements state to set active measurementId
// TODO: Not yet implemented
//dispatch(setActiveMeasurement(measurementData.measurementId))
// (later): Needs to set some property on state.extensions.cornerstone to synchronize viewport scrolling
},
};
};
const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
return {
timepoints: propsFromState.timepoints,
measurementCollection: propsFromState.measurementCollection,
selectedMeasurementNumber: ownProps.selectedMeasurementNumber,
...propsFromDispatch,
onItemClick: (event, measurementData) => {
// TODO: Add timepointId to .data for measurementData?
// TODO: Tooltype should be on the level below? This should
// provide the entire row item?
const viewportsState = propsFromState.viewports;
const timepointManagerState = propsFromState.timepointManager;
// TODO: invertViewportTimepointsOrder should be stored in / read from user preferences
// TODO: childToolKey should come from the measurement table when it supports child tools
const options = {
invertViewportTimepointsOrder: false,
childToolKey: null,
};
propsFromDispatch.dispatchJumpToRowItem(
measurementData,
viewportsState,
timepointManagerState,
options
);
},
onRelabelClick: (event, measurementData) => {
const viewportsState = propsFromState.viewports;
propsFromDispatch.dispatchRelabel(event, measurementData, viewportsState);
},
onEditDescriptionClick: (event, measurementData) => {
const viewportsState = propsFromState.viewports;
propsFromDispatch.dispatchEditDescription(
event,
measurementData,
viewportsState
);
},
onDeleteClick: (event, measurementData) => {
const { MeasurementHandlers } = OHIF.measurements;
MeasurementHandlers.onRemoved({
detail: {
toolType: measurementData.toolType,
measurementData: {
_id: measurementData.measurementId,
lesionNamingNumber: measurementData.lesionNamingNumber,
measurementNumber: measurementData.measurementNumber,
},
},
});
},
};
};
const ConnectedMeasurementTable = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(MeasurementTable);
export default ConnectedMeasurementTable;
@@ -0,0 +1,23 @@
import ConnectedMeasurementTable from './ConnectedMeasurementTable.js';
export default {
id: 'measurements-table',
getPanelModule() {
return {
menuOptions: [
{
icon: 'list',
label: 'Measurements',
target: 'measurement-panel',
},
],
components: [
{
id: 'measurement-panel',
component: ConnectedMeasurementTable,
},
],
defaultContext: ['VIEWER'],
};
},
};
@@ -0,0 +1,111 @@
import { measurements, utils } from "@ohif/core";
const { MeasurementApi } = measurements;
const { studyMetadataManager } = utils;
// TODO: Move this function to OHIF itself so we can use it on the OHIF measurment table (when it is finished)
/**
* Activates a set of measurements
*
* @param measurementData
* @param viewportsState
* @param timepointManagerState
* @param options
*/
export default function jumpToRowItem(
measurementData,
viewportsState,
timepointManagerState,
options = { invertViewportTimepointsOrder: false, childToolKey: null }
) {
const numViewports = viewportsState.layout.viewports.length;
const numTimepoints = timepointManagerState.timepoints.length;
const { measurements, timepoints } = timepointManagerState;
const numViewportsToUpdate = Math.min(numTimepoints, numViewports);
const { toolType, measurementNumber } = measurementData;
if (options.invertViewportTimepointsOrder) {
timepoints.reverse();
}
const measurementsForToolGroup = measurements[toolType];
// Retrieve the measurements data
const measurementsToJumpTo = [];
for (let i = 0; i < numViewportsToUpdate; i++) {
const { timepointId } = timepoints[i];
const dataAtThisTimepoint = measurementsForToolGroup.find(entry => {
return (
entry.timepointId === timepointId &&
entry.measurementNumber === measurementNumber
);
});
if (!dataAtThisTimepoint) {
measurementsToJumpTo.push(null);
continue;
}
let measurement = dataAtThisTimepoint;
const { tool } = MeasurementApi.getToolConfiguration(toolType);
if (options.childToolKey) {
measurement = dataAtThisTimepoint[options.childToolKey];
} else if (Array.isArray(tool.childTools)) {
const key = tool.childTools.find(key => !!dataAtThisTimepoint[key]);
measurement = dataAtThisTimepoint[key];
}
measurementsToJumpTo.push(measurement);
}
// TODO: Add a single viewports state action which allows
// - viewportData to be set
// - layout to be set
// - activeViewport to be set
// Needs to update viewports.viewportData state to set image set data
const displaySetContainsSopInstance = (displaySet, sopInstanceUid) =>
displaySet.images.find(
image => image.getSOPInstanceUID() === sopInstanceUid
);
const viewportSpecificData = [];
measurementsToJumpTo.forEach((data, viewportIndex) => {
// Skip if there is no measurement to jump
if (!data) {
return;
}
const study = studyMetadataManager.get(data.studyInstanceUid);
if (!study) {
throw new Error("Study not found.");
}
const displaySet = study.findDisplaySet(displaySet => {
return displaySetContainsSopInstance(displaySet, data.sopInstanceUid);
});
if (!displaySet) {
throw new Error("Display set not found.");
}
displaySet.sopInstanceUid = data.sopInstanceUid;
if (data.frameIndex) {
displaySet.frameIndex = data.frameIndex;
}
viewportSpecificData.push({
viewportIndex,
displaySet
});
});
return {
viewportSpecificData,
layout: [] // TODO: if we need to change layout, we should return this here
};
}
@@ -0,0 +1,4 @@
import GenericViewerCommands from './GenericViewerCommands/index.js';
import MeasurementsPanel from './MeasurementsPanel/index.js';
export { GenericViewerCommands, MeasurementsPanel };
@@ -0,0 +1,37 @@
/**
* We use this component to leverage "Code Splitting"
*
* Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
*/
import React, { Component } from 'react';
export default function asyncComponent(importComponent) {
class AsyncComponent extends Component {
constructor(props) {
super(props);
this.state = {
component: null,
};
}
async componentDidMount() {
// Add dynamically loaded component to state
const { default: component } = await importComponent();
this.setState({
component: component,
});
}
render() {
const C = this.state.component;
// Render the loaded component, or null
return C ? <C {...this.props} /> : null;
}
}
return AsyncComponent;
}
@@ -0,0 +1,6 @@
.editDescriptionDialog {
position: absolute;
z-index: 300;
width: 320px;
transition: all 300ms linear;
}
@@ -0,0 +1,83 @@
import { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import SimpleDialog from '../SimpleDialog/SimpleDialog.js';
import bounding from '../../lib/utils/bounding.js';
import { getDialogStyle } from './../Labelling/labellingPositionUtils.js';
import './EditDescriptionDialog.css';
export default class EditDescriptionDialog extends Component {
static defaultProps = {
componentRef: React.createRef(),
componentStyle: {},
};
static propTypes = {
measurementData: PropTypes.object.isRequired,
onCancel: PropTypes.func.isRequired,
componentRef: PropTypes.object,
componentStyle: PropTypes.object,
onUpdate: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
description: props.measurementData.description || '',
};
this.mainElement = React.createRef();
}
componentDidMount = () => {
bounding(this.mainElement);
};
componentDidUpdate(prevProps) {
if (this.props.description !== prevProps.description) {
this.setState({
description: this.props.description,
});
}
}
render() {
const style = getDialogStyle(this.props.componentStyle);
return (
<SimpleDialog
headerTitle="Edit Description"
onClose={this.onClose}
onConfirm={this.onConfirm}
rootClass="editDescriptionDialog"
componentRef={this.mainElement}
componentStyle={style}
>
<input
value={this.state.description}
className="simpleDialogInput"
id="description"
autoComplete="off"
autoFocus
onChange={this.handleChange}
/>
</SimpleDialog>
);
}
onClose = () => {
this.props.onCancel();
};
onConfirm = e => {
e.preventDefault();
this.props.onUpdate(this.state.description);
};
handleChange = event => {
this.setState({ description: event.target.value });
};
}
@@ -0,0 +1,108 @@
.entry-header {
padding: 10px 10px 0;
height: var(--top-bar-height);
}
.entry-header.header-big {
background: rgba(21,25,30,0.7);
padding: 10px var(--study-list-padding);
height: auto;
display: inline-block;
width: 100%;
}
.header-left-box {
display: inline-block;
position: relative;
}
.header-viewerLink {
float: left;
}
.header-brand {
height: 30px;
text-decoration: none;
float: left;
clear: both;
}
.entry-header.header-big .header-brand {
height: auto;
padding: 25px 0;
}
.header-logo-image {
fill: transparent;
height: 100%;
margin: 0 8px 0 0;
width: 30px;
float: left;
}
.entry-header.header-big .header-logo-image {
margin: 0 20px 0 0;
width: 50px;
height: 50px;
}
.header-logo-text {
display: inline-block;
font-family: var(--logo-font-family);
font-size: 14px;
font-weight: var(--logo-font-weight);
color: var(--text-primary-color);
line-height: 30px;
}
.entry-header.header-big .header-logo-text {
font-size: 30px;
line-height: 36px;
}
.header-btn {
color: var(--text-secondary-color);
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 26px;
}
.header-btn:hover {
color: var(--hover-color);
}
.header-btn:active {
color: var(--active-color);
}
.header-studyListLinkSection {
border-left: var(--ui-border-thickness) solid var(--ui-border-color);
margin: 3px 0 0 10px;
padding: 0 0 0 10px;
display: inline-block;
}
.header-menu {
color: var(--text-primary-color);
float: right;
padding: 4px 0;
font-size: 13px;
font-weight: 400;
line-height: 18px;
text-decoration: none;
margin-right: 10px;
}
.entry-header.header-big .header-menu {
margin-right: 0;
}
.header-menu .research-use {
float: left;
font-size: 13px;
font-weight: 700;
color: #91b9cd;
margin-right: 1rem;
}
@@ -0,0 +1,133 @@
import './Header.css';
import './Header.css';
import { Link, withRouter } from 'react-router-dom';
import React, { Component } from 'react';
import { Dropdown } from '@ohif/ui';
import OHIFLogo from '../OHIFLogo/OHIFLogo.js';
import PropTypes from 'prop-types';
import { AboutModal } from '@ohif/ui';
import { hotkeysManager } from './../../App.js';
import { withTranslation } from 'react-i18next';
class Header extends Component {
static propTypes = {
home: PropTypes.bool.isRequired,
location: PropTypes.object.isRequired,
children: PropTypes.node,
t: PropTypes.func.isRequired,
userManager: PropTypes.object
};
static defaultProps = {
home: true,
children: OHIFLogo(),
};
// onSave: data => {
// const contextName = store.getState().commandContext.context;
// const preferences = cloneDeep(store.getState().preferences);
// preferences[contextName] = data;
// dispatch(setUserPreferences(preferences));
// dispatch(setUserPreferencesModalOpen(false));
// OHIF.hotkeysUtil.setHotkeys(data.hotKeysData);
// },
// onResetToDefaults: () => {
// dispatch(setUserPreferences());
// dispatch(setUserPreferencesModalOpen(false));
// OHIF.hotkeysUtil.setHotkeys();
// },
constructor(props) {
super(props);
this.state = { isUserPreferencesOpen: false, isOpen: false };
this.loadOptions();
}
loadOptions() {
const { t } = this.props;
this.options = [
{
title: t('About'),
icon: { name: 'info' },
onClick: () => {
this.setState({
isOpen: true,
});
},
},
];
if (this.props.user && this.props.userManager) {
this.options.push({
title: t('Logout'),
icon: { name: 'power-off' },
onClick: () => {
this.props.userManager.signoutRedirect();
},
});
}
this.hotKeysData = hotkeysManager.hotkeyDefinitions;
}
onUserPreferencesSave({ windowLevelData, hotKeysData }) {
// console.log(windowLevelData);
// console.log(hotKeysData);
// TODO: Update hotkeysManager
// TODO: reset `this.hotKeysData`
}
render() {
const { t } = this.props;
const showStudyList =
window.config.showStudyList !== undefined
? window.config.showStudyList
: true;
return (
<div className={`entry-header ${this.props.home ? 'header-big' : ''}`}>
<div className="header-left-box">
{this.props.location && this.props.location.studyLink && (
<Link
to={this.props.location.studyLink}
className="header-btn header-viewerLink"
>
{t('Back to Viewer')}
</Link>
)}
{this.props.children}
{showStudyList && !this.props.home && (
<Link
className="header-btn header-studyListLinkSection"
to={{
pathname: '/',
state: { studyLink: this.props.location.pathname },
}}
>
{t('Study list')}
</Link>
)}
</div>
<div className="header-menu">
<span className="research-use">{t('INVESTIGATIONAL USE ONLY')}</span>
<Dropdown title={t('Options')} list={this.options} align="right" />
<AboutModal
{...this.state}
onCancel={() =>
this.setState({
isOpen: false,
})
}
/>
</div>
</div>
);
}
}
export default withTranslation('Header')(withRouter(Header));
@@ -0,0 +1,3 @@
import Header from './Header';
export default Header;
@@ -0,0 +1,317 @@
import { Icon, SelectTree } from '@ohif/ui';
import React, { Component } from 'react';
import LabellingTransition from './LabellingTransition.js';
import OHIFLabellingData from './OHIFLabellingData.js';
import PropTypes from 'prop-types';
import bounding from '../../lib/utils/bounding.js';
import cloneDeep from 'lodash.clonedeep';
import { getAddLabelButtonStyle } from './labellingPositionUtils.js';
export default class LabellingFlow extends Component {
static propTypes = {
eventData: PropTypes.object.isRequired,
measurementData: PropTypes.object.isRequired,
labellingDoneCallback: PropTypes.func.isRequired,
updateLabelling: PropTypes.func.isRequired,
initialTopDistance: PropTypes.number,
skipAddLabelButton: PropTypes.bool,
editLocation: PropTypes.bool,
editDescription: PropTypes.bool,
};
constructor(props) {
super(props);
const { location, locationLabel, description } = props.measurementData;
let style = props.componentStyle;
if (!props.skipAddLabelButton) {
style = getAddLabelButtonStyle(props.measurementData, props.eventData);
}
this.state = {
location,
locationLabel,
description,
skipAddLabelButton: props.skipAddLabelButton,
editDescription: props.editDescription,
editLocation: props.editLocation,
componentStyle: style,
confirmationState: false,
displayComponent: true,
};
this.mainElement = React.createRef();
this.descriptionInput = React.createRef();
this.initialItems = OHIFLabellingData;
this.currentItems = cloneDeep(this.initialItems);
}
componentDidUpdate = () => {
this.repositionComponent();
if (this.state.editDescription) {
this.descriptionInput.current.focus();
}
};
render() {
let mainElementClassName = 'labellingComponent';
if (this.state.editDescription) {
mainElementClassName += ' editDescription';
}
const style = Object.assign({}, this.state.componentStyle);
if (this.state.skipAddLabelButton) {
if (style.left - 160 < 0) {
style.left = 0;
} else {
style.left -= 160;
}
}
if (this.state.editLocation) {
style.maxHeight = '70vh';
if (!this.initialTopDistance) {
this.initialTopDistance = window.innerHeight - window.innerHeight * 0.3;
style.top = `${this.state.componentStyle.top -
this.initialTopDistance / 2}px`;
} else {
style.top = `${this.state.componentStyle.top}px`;
}
}
return (
<LabellingTransition
displayComponent={this.state.displayComponent}
onTransitionExit={this.props.labellingDoneCallback}
>
<>
<div className="labellingComponent-overlay"></div>
<div
className={mainElementClassName}
style={style}
ref={this.mainElement}
onMouseLeave={this.fadeOutAndLeave}
onMouseEnter={this.clearFadeOutTimer}
>
{this.labellingStateFragment()}
</div>
</>
</LabellingTransition>
);
}
labellingStateFragment = () => {
const {
skipAddLabelButton,
editLocation,
description,
locationLabel,
} = this.state;
if (!skipAddLabelButton) {
return (
<>
<button
type="button"
className="addLabelButton"
onClick={this.showLabelling}
>
{this.state.location ? 'Edit' : 'Add'} Label
</button>
</>
);
} else {
if (editLocation) {
return (
<SelectTree
items={this.currentItems}
columns={1}
onSelected={this.selectTreeSelectCalback}
selectTreeFirstTitle="Assign Label"
onComponentChange={this.repositionComponent}
/>
);
} else {
return (
<>
<div
className="checkIconWrapper"
onClick={this.fadeOutAndLeaveFast}
>
<Icon name="check" className="checkIcon" />
</div>
<div className="locationDescriptionWrapper">
<div className="location">{locationLabel}</div>
<div className="description">
<input
id="descriptionInput"
ref={this.descriptionInput}
defaultValue={description || ''}
autoComplete="off"
/>
</div>
</div>
<div className="commonButtons">
<button
type="button"
className="commonButton left"
onClick={this.relabel}
>
Relabel
</button>
<button
type="button"
className="commonButton right"
onClick={this.setDescriptionUpdateMode}
>
{description ? 'Edit ' : 'Add '}
Description
</button>
</div>
<div className="editDescriptionButtons">
<button
type="button"
className="commonButton left"
onClick={this.descriptionCancel}
>
Cancel
</button>
<button
type="button"
className="commonButton right"
onClick={this.descriptionSave}
>
Save
</button>
</div>
</>
);
}
}
};
relabel = event => {
const viewportTopPosition = this.mainElement.current.offsetParent.offsetTop;
const componentStyle = {
top: event.nativeEvent.y - viewportTopPosition - 55,
left: event.nativeEvent.x,
};
this.setState({
editLocation: true,
componentStyle,
});
};
setDescriptionUpdateMode = () => {
this.descriptionInput.current.focus();
this.setState({
editDescription: true,
});
};
descriptionCancel = () => {
const { description = '' } = cloneDeep(this.state);
this.descriptionInput.current.value = description;
this.setState({
editDescription: false,
});
};
descriptionSave = () => {
const description = this.descriptionInput.current.value;
this.props.updateLabelling({ description });
this.setState({
description,
editDescription: false,
});
};
selectTreeSelectCalback = (event, itemSelected) => {
const location = itemSelected.value;
this.props.updateLabelling({ location });
const viewportTopPosition = this.mainElement.current.offsetParent.offsetTop;
const componentStyle = {
top: event.nativeEvent.y - viewportTopPosition - 25,
left: event.nativeEvent.x,
};
this.setState({
editLocation: false,
confirmationState: true,
location: itemSelected.value,
locationLabel: itemSelected.label,
componentStyle,
});
if (this.isTouchScreen) {
this.setTimeout = setTimeout(() => {
this.setState({
displayComponent: false,
});
}, 2000);
}
};
showLabelling = () => {
this.setState({
skipAddLabelButton: true,
editLocation: false,
});
};
fadeOutAndLeave = () => {
// Wait for 1 sec to dismiss the labelling component
this.fadeOutTimer = setTimeout(() => {
this.setState({
displayComponent: false,
});
}, 1000);
};
fadeOutAndLeaveFast = () => {
this.setState({
displayComponent: false,
});
};
clearFadeOutTimer = () => {
if (!this.fadeOutTimer) {
return;
}
clearTimeout(this.fadeOutTimer);
};
calculateTopDistance = () => {
const height = window.innerHeight - window.innerHeight * 0.3;
let top = this.state.componentStyle.top - height / 2 + 55;
if (top < 0) {
top = 0;
} else {
if (top + height > window.innerHeight) {
top -= top + height - window.innerHeight;
}
}
return top;
};
repositionComponent = () => {
// SetTimeout for the css animation to end.
setTimeout(() => {
bounding(this.mainElement);
if (this.state.editLocation) {
this.mainElement.current.style.maxHeight = '70vh';
const top = this.calculateTopDistance();
this.mainElement.current.style.top = `${top}px`;
}
}, 200);
};
}
@@ -0,0 +1,140 @@
.labellingComponent-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background-color: rgba(0, 0, 0, 0.8);
}
.labellingComponent {
position: absolute;
text-align: center;
z-index: 999;
transition: all 200ms linear;
}
.labellingComponent .selectedLabel,
.labellingComponent .selectedDescription {
padding: 5px;
background-color: white;
width: 150px;
}
.labellingComponent .addLabelButton {
color: #000000;
background-color: #20a5d6;
border: 2px solid #44626f;
border-radius: 14px;
cursor: pointer;
font-weight: bold;
font-size: 13px;
line-height: 24px;
opacity: 1;
padding: 0 14px;
transition: opacity 0.3s ease;
outline: none;
cursor: pointer;
}
.labellingComponent .commonButton {
border: 1px solid #44626f;
color: #ffffff;
background-color: #000000;
border-radius: 16px;
font-weight: bold;
font-size: 13px;
line-height: 26px;
padding: 0 12px;
margin: 10px 5px 0 0;
outline: none;
cursor: pointer;
}
.labellingComponent .locationDescriptionWrapper {
background-color: #ffffff;
line-height: 46px;
height: 46px;
font-size: 13px;
position: relative;
width: 290px;
min-width: 260px;
padding: 0 12px;
margin: 0 auto;
display: inline-block;
}
.labellingComponent .locationDescriptionWrapper .location {
transition: all 300ms linear;
position: absolute;
white-space: nowrap;
bottom: 0;
}
.labellingComponent.editDescription .locationDescriptionWrapper .location {
bottom: 38px;
}
.labellingComponent .locationDescriptionWrapper #descriptionInput {
transition-delay: all 300ms linear;
visibility: hidden;
outline: none;
height: 46px;
width: 100%;
line-height: 20px;
font-size: 13px;
border: none;
}
.labellingComponent.editDescription
.locationDescriptionWrapper
#descriptionInput {
visibility: visible;
}
.labellingComponent.editDescription .location {
color: #337ab7;
}
.labellingComponent .commonButtons,
.labellingComponent.editDescription .editDescriptionButtons {
display: block;
margin-left: 55px;
}
.labellingComponent.editDescription .commonButtons,
.labellingComponent .editDescriptionButtons {
display: none;
}
.labellingComponent .commonButtons {
text-align: center;
margin-left: 55px;
}
.labellingComponent .commonButton.left {
float: left;
}
.labellingComponent .commonButton.right {
float: right;
}
.labellingComponent .checkIconWrapper {
display: inline-block;
background-color: #337ab7;
border-radius: 46px;
width: 46px;
height: 46px;
margin-right: 10px;
vertical-align: bottom;
cursor: pointer;
}
.labellingComponent .checkIcon {
width: 20px;
height: 20px;
margin: 13px;
fill: black;
}
@@ -0,0 +1,130 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cloneDeep from 'lodash.clonedeep';
import EditDescriptionDialog from './../EditDescriptionDialog/EditDescriptionDialog.js';
import LabellingFlow from './LabellingFlow.js';
import './LabellingManager.css';
export default class LabellingManager extends Component {
static propTypes = {
eventData: PropTypes.object.isRequired,
measurementData: PropTypes.object.isRequired,
labellingDoneCallback: PropTypes.func.isRequired,
updateLabelling: PropTypes.func.isRequired,
skipAddLabelButton: PropTypes.bool,
editLocation: PropTypes.bool,
editDescription: PropTypes.bool,
editDescriptionOnDialog: PropTypes.bool,
};
static defaultProps = {
skipAddLabelButton: false,
editLocation: false,
editDescription: false,
editDescriptionOnDialog: false,
};
constructor(props) {
super(props);
const measurementData = cloneDeep(props.measurementData);
this.treatMeasurementData(measurementData);
let editLocation = props.editLocation;
if (!props.editDescription && !props.editLocation) {
editLocation = true;
}
this.state = {
componentStyle: getComponentPosition(props.eventData),
skipAddLabelButton: props.skipAddLabelButton,
editLocation: editLocation,
editDescription: props.editDescription,
editDescriptionOnDialog: props.editDescriptionOnDialog,
measurementData: measurementData,
};
}
componentDidMount = () => {
document.addEventListener('touchstart', this.onTouchStart);
};
componentWillUnmount = () => {
document.removeEventListener('touchstart', this.onTouchStart);
};
render() {
return this.getRenderComponent();
}
getRenderComponent = () => {
const {
editLocation,
editDescription,
editDescriptionOnDialog,
measurementData,
} = this.state;
if (editDescriptionOnDialog) {
return (
<EditDescriptionDialog
onCancel={this.props.labellingDoneCallback}
onUpdate={this.descriptionDialogUpdate}
componentRef={this.editDescriptionDialog}
componentStyle={this.state.componentStyle}
measurementData={measurementData}
/>
);
}
if (editLocation || editDescription) {
return (
<LabellingFlow
{...this.props}
componentStyle={this.state.componentStyle}
/>
);
}
};
treatMeasurementData = measurementData => {
const { editDescription, editLocation } = this.props;
if (editDescription) {
measurementData.description = undefined;
}
if (editLocation) {
measurementData.location = undefined;
}
};
responseDialogUpdate = response => {
this.props.updateLabelling({
response,
});
this.props.labellingDoneCallback();
};
descriptionDialogUpdate = description => {
this.props.updateLabelling({
description,
});
this.props.labellingDoneCallback();
};
}
function getComponentPosition(eventData) {
const {
event: { clientX: left, clientY: top },
} = eventData;
return {
left,
top,
};
}
@@ -0,0 +1,21 @@
.labelling-appear {
opacity: 0;
}
.labelling-appear.labelling-appear-active {
opacity: 1;
transition: opacity 500ms linear;
}
.labelling-exit {
opacity: 1;
}
.labelling-exit.labelling-exit-active {
opacity: 0;
transition: opacity 500ms linear;
}
.labelling-exit-done {
opacity: 0;
}
@@ -0,0 +1,31 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { CSSTransition } from 'react-transition-group';
import './LabellingTransition.css';
// If these variables changes, CSS must be updated
const transitionDuration = 500;
const transitionClassName = 'labelling';
const transitionOnAppear = true;
export default class LabellingTransition extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
displayComponent: PropTypes.bool.isRequired,
onTransitionExit: PropTypes.func.isRequired,
};
render() {
return (
<CSSTransition
in={this.props.displayComponent}
appear={transitionOnAppear}
timeout={transitionDuration}
classNames={transitionClassName}
onExited={this.props.onTransitionExit}
>
{this.props.children}
</CSSTransition>
);
}
}
@@ -0,0 +1,39 @@
const items = [
'Abdomen/Chest Wall',
'Adrenal',
'Bladder',
'Bone',
'Brain',
'Breast',
'Colon',
'Esophagus',
'Extremities',
'Gallbladder',
'Kidney',
'Liver',
'Lung',
'Lymph Node',
'Mediastinum/Hilum',
'Muscle',
'Neck',
'Other Soft Tissue',
'Ovary',
'Pancreas',
'Pelvis',
'Peritoneum/Omentum',
'Prostate',
'Retroperitoneum',
'Small Bowel',
'Spleen',
'Stomach',
'Subcutaneous',
];
const OHIFLabellingData = items.map(item => {
return {
label: item,
value: item,
};
});
export default OHIFLabellingData;
@@ -0,0 +1,53 @@
import cornerstone from 'cornerstone-core';
const buttonSize = {
width: 96,
height: 28,
};
export function getAddLabelButtonStyle(measurementData, eventData) {
const { start, end } = measurementData.handles;
const { client } = eventData.currentPoints;
const clientStart = cornerstone.pixelToCanvas(eventData.element, start);
const clientEnd = cornerstone.pixelToCanvas(eventData.element, end);
const canvasOffSetLeft = client.x - clientStart.x;
const canvasOffSetTop = client.y - clientStart.y;
const position = {
left: clientEnd.x + canvasOffSetLeft,
top: clientEnd.y + canvasOffSetTop,
};
if (start.y > end.y) {
position.top -= buttonSize.height;
}
if (start.x > end.x) {
position.left -= buttonSize.width;
}
return position;
}
export function getDialogStyle(componentStyle) {
const style = Object.assign({}, componentStyle);
const dialogProps = {
width: 320,
height: 230,
};
// Get max values to avoid position out of the screen
const maxLeft = window.innerWidth - dialogProps.width;
const maxTop = window.innerHeight - dialogProps.height;
// Positioning the dialog with its center on the click event
style.left -= dialogProps.width / 2;
style.top -= dialogProps.height / 2;
if (style.left > maxLeft) {
style.left = maxLeft;
}
if (style.top > maxTop) {
style.top = maxTop;
}
return style;
}
@@ -0,0 +1,32 @@
import React from 'react';
const Bar = ({ progress, animationDuration }) => (
<div
style={{
background: '#29d',
height: 2,
left: 0,
marginLeft: `${(-1 + progress) * 100}%`,
position: 'fixed',
top: 0,
transition: `margin-left ${animationDuration}ms linear`,
width: '100%',
zIndex: 1031,
}}
>
<div
style={{
boxShadow: '0 0 10px #29d, 0 0 5px #29d',
display: 'block',
height: '100%',
opacity: 1,
position: 'absolute',
right: 0,
transform: 'rotate(3deg) translate(0px, -4px)',
width: 100,
}}
/>
</div>
);
export default Bar;
@@ -0,0 +1,15 @@
import React from 'react';
const Container = ({ children, isFinished, animationDuration }) => (
<div
style={{
opacity: isFinished ? 0 : 1,
pointerEvents: 'none',
transition: `opacity ${animationDuration}ms linear`,
}}
>
{children}
</div>
);
export default Container;
@@ -0,0 +1,4 @@
import Bar from './Bar.js';
import Container from './Container.js';
export { Bar, Container };
@@ -0,0 +1,29 @@
.header-brand {
height: 30px;
text-decoration: none;
text-decoration-color: none;
display: flex;
color: var(--text-primary-color);
font-size: 14px;
font-weight: var(--logo-font-weight);
font-family: var(--logo-font-family);
}
.header-brand:hover,
.header-brand:active,
.header-band:visited {
color: var(--text-primary-color);
text-decoration: none;
}
.header-logo-text {
line-height: 30px;
margin-left: 8px;
}
.header-logo-image {
height: 30px;
width: 30px;
font-size: 30px;
}
@@ -0,0 +1,20 @@
import './OHIFLogo.css';
import { Icon } from '@ohif/ui';
import React from 'react';
function OHIFLogo() {
return (
<a
target="_blank"
rel="noopener noreferrer"
className="header-brand"
href="http://ohif.org"
>
<Icon name="ohif-logo" className="header-logo-image" />
<div className="header-logo-text">Open Health Imaging Foundation</div>
</a>
);
}
export default OHIFLogo;
@@ -0,0 +1,40 @@
.FlexboxLayout {
display: flex;
flex: 1;
flex-flow: row nowrap;
align-items: stretch;
height: calc(100% - var(--toolbar-height) - var(--top-bar-height));
width: 100%;
overflow: hidden;
}
.sidepanel {
flex: 1;
height: 100%;
transition: var(--sidepanel-transition);
}
.from-left {
border-right: var(--ui-border-thickness) solid var(--ui-border-color);
margin-left: calc(var(--left-sidepanel-menu-width) * -1);
max-width: var(--left-sidepanel-menu-width);
}
.from-right {
border-left: var(--ui-border-thickness) solid var(--ui-border-color);
margin-right: calc(var(--right-sidepanel-menu-width) * -1);
max-width: var(--right-sidepanel-menu-width);
}
.sidepanel.is-open {
margin-right: 0;
margin-left: 0;
}
.main-content {
flex: 1;
height: 100%;
overflow: hidden;
transition: var(--sidepanel-transition);
width: 100%;
}
@@ -0,0 +1,41 @@
import './SidePanel.css';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
class SidePanel extends Component {
static propTypes = {
from: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
children: PropTypes.node,
width: PropTypes.string,
};
render() {
const fromSideClass =
this.props.from === 'right' ? 'from-right' : 'from-left';
const styles = this.props.width
? {
maxWidth: this.props.width,
marginRight: this.props.isOpen
? '0'
: Number.parseInt(this.props.width) * -1,
}
: {};
return (
<section
style={styles}
className={classNames('sidepanel', fromSideClass, {
'is-open': this.props.isOpen,
})}
>
{this.props.children}
</section>
);
}
}
export default SidePanel;
@@ -0,0 +1,133 @@
.simpleDialog {
position: fixed;
border: 0;
border-radius: 6px;
background-color: #151a1f;
}
.simpleDialog .header {
border-bottom-width: 3px;
border-bottom-style: solid;
border-bottom-color: #000;
padding: 19px 22px 17px;
position: relative;
}
.simpleDialog .header .title {
font-size: 20px;
font-weight: 600;
line-height: 24px;
padding-right: 40px;
color: #91b9cd;
margin: 0;
}
.simpleDialog .header .closeBtn {
height: 20px;
opacity: 1;
overflow: hidden;
padding: 2px;
text-align: center;
text-shadow: none;
width: 20px;
color: #91b9cd;
cursor: pointer;
position: absolute;
right: 21px;
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
transition: color 0.3s ease;
}
.simpleDialog .header .closeIcon {
color: transparent;
display: block;
font-size: 0;
height: 100%;
line-height: 0;
overflow: hidden;
position: relative;
width: 100%;
}
.simpleDialog .header .closeIcon:after,
.simpleDialog .header .closeIcon:before {
content: ' ';
display: block;
height: 2px;
transition: background-color 0.3s ease;
width: 19px;
background-color: #91b9cd;
}
.simpleDialog .header .closeIcon:before {
left: 1px;
position: absolute;
top: 1px;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
-webkit-transform-origin: 1px 50%;
transform-origin: 1px 50%;
}
.simpleDialog .header .closeIcon:after {
right: 1px;
position: absolute;
top: 1px;
-webkit-transform: rotate(-45deg);
transform: rotate(-45deg);
-webkit-transform-origin: calc(100% - 1px) 50%;
transform-origin: calc(100% - 1px) 50%;
}
.simpleDialog .content {
padding: 16px 22px 25px;
position: relative;
color: #fff;
}
.simpleDialog .content .simpleDialogSelect,
.simpleDialog .content .simpleDialogInput {
background-color: #fff;
color: #000;
border: 0;
border-radius: 2px;
font-size: 14px;
height: 30px;
width: 100%;
line-height: 16px;
padding: 8px 9px 6px;
margin-top: 10px;
display: block;
}
.simpleDialog .content .simpleDialogInputLabel {
font-size: 14px;
font-weight: 700;
line-height: 16px;
color: #fff;
}
.simpleDialog .footer {
padding: 15px;
text-align: right;
}
.simpleDialog .footer .btn {
transition: background-color 0.3s ease;
color: #000;
border: 0;
border-radius: 4px;
font-size: 15px;
font-weight: 400;
height: 37px;
line-height: 37px;
padding: 0 12px;
margin-bottom: 0;
margin-left: 5px;
}
.simpleDialog .footer .btn-confirm {
color: #fff;
background-color: #337ab7;
}
@@ -0,0 +1,56 @@
import { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import './SimpleDialog.css';
class SimpleDialog extends Component {
static defaultProps = {
componentStyle: {},
rootClass: '',
};
render() {
return (
<div
className={`simpleDialog ${this.props.rootClass}`}
ref={this.props.componentRef}
style={this.props.componentStyle}
>
<form onSubmit={this.props.onConfirm}>
<div className="header">
<span className="closeBtn" onClick={this.props.onClose}>
<span className="closeIcon">x</span>
</span>
<h4 className="title">{this.props.headerTitle}</h4>
</div>
<div className="content">{this.props.children}</div>
<div className="footer">
<button
type="button"
className="btn btn-cancel"
onClick={this.props.onClose}
>
Cancel
</button>
<button
type="button"
className="btn btn-confirm"
onClick={this.props.onConfirm}
>
Confirm
</button>
</div>
</form>
</div>
);
}
}
SimpleDialog.propTypes = {
headerTitle: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
};
export default SimpleDialog;
@@ -0,0 +1,42 @@
import { Component } from "react";
import PropTypes from "prop-types";
import OHIF from "@ohif/core";
class StudyLoadingMonitor extends Component {
static propTypes = {
studies: PropTypes.array.isRequired,
setStudyLoadingProgress: PropTypes.func.isRequired,
clearStudyLoadingProgress: PropTypes.func.isRequired
};
componentDidMount() {
// TODO: This is pretty ugly. The thing is that the StudyLoadingListener
// needs to update the Redux store, but shouldn't know that it exists.
// I am therefore passing in some functions to update the store instead,
// but this should definitely be cleaned up somehow.
const options = {
_setProgressData: (progressId, progressData) => {
this.props.setStudyLoadingProgress(progressId, progressData);
},
_clearProgressById: progressId => {
this.props.clearStudyLoadingProgress(progressId);
}
};
const { StudyLoadingListener } = OHIF.classes;
this.studyLoadingListener = StudyLoadingListener.getInstance(options);
this.studyLoadingListener.clear();
this.studyLoadingListener.addStudies(this.props.studies);
}
render() {
return null;
}
componentWillUnmount() {
// Destroy stack loading listeners when we close the viewer
this.studyLoadingListener.clear();
}
}
export default StudyLoadingMonitor;
@@ -0,0 +1,29 @@
import { Component } from "react";
import PropTypes from "prop-types";
import OHIF from "@ohif/core";
const { StudyPrefetcher } = OHIF.classes;
class StudyPrefetcherComponent extends Component {
static propTypes = {
studies: PropTypes.array
};
componentDidMount() {
const { studies } = this.props;
this.studyPrefetcher = StudyPrefetcher.getInstance();
this.studyPrefetcher.setStudies(studies);
}
render() {
return null;
}
componentWillUnmount() {
// Stop prefetching when we close the viewer
this.studyPrefetcher.destroy();
}
}
export default StudyPrefetcherComponent;
+45
View File
@@ -0,0 +1,45 @@
import OHIF from '@ohif/core';
import cornerstone from 'cornerstone-core';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import dicomParser from 'dicom-parser';
import version from './version.js';
let homepage;
const { process } = window;
if (process && process.env && process.env.PUBLIC_URL) {
homepage = process.env.PUBLIC_URL;
}
window.info = {
version,
homepage,
};
// For debugging
//if (process.env.node_env === 'development') {
window.cornerstone = cornerstone;
window.cornerstoneWADOImageLoader = cornerstoneWADOImageLoader;
//}
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
OHIF.user.getAccessToken = () => {
// TODO: Get the Redux store from somewhere else
const state = window.store.getState();
if (!state.oidc || !state.oidc.user) {
return;
}
return state.oidc.user.access_token;
};
cornerstoneWADOImageLoader.configure({
beforeSend: function(xhr) {
const headers = OHIF.DICOMWeb.getAuthorizationHeader();
if (headers.Authorization) {
xhr.setRequestHeader('Authorization', headers.Authorization);
}
},
});
@@ -0,0 +1,102 @@
import { connect } from 'react-redux';
import { CineDialog } from '@ohif/ui';
import OHIF from '@ohif/core';
import csTools from 'cornerstone-tools';
// Our target output kills the `as` and "import" throws a keyword error
// import { import as toolImport, getToolState } from 'cornerstone-tools';
import cloneDeep from 'lodash.clonedeep';
const toolImport = csTools.import;
const scrollToIndex = toolImport('util/scrollToIndex');
const { setViewportSpecificData } = OHIF.redux.actions;
// Why do I need or care about any of this info?
// A dispatch action should be able to pull this at the time of an event?
// `isPlaying` and `cineFrameRate` might matter, but I think we can prop pass for those.
const mapStateToProps = state => {
// Get activeViewport's `cine` and `stack`
const { viewportSpecificData, activeViewportIndex } = state.viewports;
const { cine, dom } = viewportSpecificData[activeViewportIndex] || {};
const cineData = cine || {
isPlaying: false,
cineFrameRate: 24,
};
// New props we're creating?
return {
activeEnabledElement: dom,
activeViewportCineData: cineData,
activeViewportIndex: state.viewports.activeViewportIndex,
};
};
const mapDispatchToProps = dispatch => {
return {
dispatchSetViewportSpecificData: (viewportIndex, data) => {
dispatch(setViewportSpecificData(viewportIndex, data));
},
};
};
const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
const {
activeEnabledElement,
activeViewportCineData,
activeViewportIndex,
} = propsFromState;
return {
cineFrameRate: activeViewportCineData.cineFrameRate,
isPlaying: activeViewportCineData.isPlaying,
onPlayPauseChanged: isPlaying => {
const cine = cloneDeep(activeViewportCineData);
cine.isPlaying = !cine.isPlaying;
propsFromDispatch.dispatchSetViewportSpecificData(activeViewportIndex, {
cine,
});
},
onFrameRateChanged: frameRate => {
const cine = cloneDeep(activeViewportCineData);
cine.cineFrameRate = frameRate;
propsFromDispatch.dispatchSetViewportSpecificData(activeViewportIndex, {
cine,
});
},
onClickNextButton: () => {
const stackData = csTools.getToolState(activeEnabledElement, 'stack');
if (!stackData || !stackData.data || !stackData.data.length) return;
const { currentImageIdIndex, imageIds } = stackData.data[0];
if (currentImageIdIndex >= imageIds.length - 1) return;
scrollToIndex(activeEnabledElement, currentImageIdIndex + 1);
},
onClickBackButton: () => {
const stackData = csTools.getToolState(activeEnabledElement, 'stack');
if (!stackData || !stackData.data || !stackData.data.length) return;
const { currentImageIdIndex } = stackData.data[0];
if (currentImageIdIndex === 0) return;
scrollToIndex(activeEnabledElement, currentImageIdIndex - 1);
},
onClickSkipToStart: () => {
const stackData = csTools.getToolState(activeEnabledElement, 'stack');
if (!stackData || !stackData.data || !stackData.data.length) return;
scrollToIndex(activeEnabledElement, 0);
},
onClickSkipToEnd: () => {
const stackData = csTools.getToolState(activeEnabledElement, 'stack');
if (!stackData || !stackData.data || !stackData.data.length) return;
const lastIndex = stackData.data[0].imageIds.length - 1;
scrollToIndex(activeEnabledElement, lastIndex);
},
};
};
const ConnectedCineDialog = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(CineDialog);
export default ConnectedCineDialog;
@@ -0,0 +1,13 @@
import Header from '../components/Header/Header.js';
import { connect } from 'react-redux';
const mapStateToProps = state => {
return {
user: state.oidc && state.oidc.user,
isOpen: state.ui.userPreferencesModalOpen,
};
};
const ConnectedHeader = connect(mapStateToProps)(Header);
export default ConnectedHeader;
@@ -0,0 +1,24 @@
import { connect } from 'react-redux';
import LabellingOverlay from './LabellingOverlay';
const mapStateToProps = state => {
if (!state.ui || !state.ui.labelling) {
return {
visible: false,
};
}
const labellingFlowData = state.ui.labelling;
return {
visible: false,
...labellingFlowData,
};
};
const ConnectedLabellingOverlay = connect(
mapStateToProps,
null
)(LabellingOverlay);
export default ConnectedLabellingOverlay;
@@ -0,0 +1,41 @@
import { LayoutButton } from '@ohif/ui';
import OHIF from '@ohif/core';
import { connect } from 'react-redux';
const { setLayout } = OHIF.redux.actions;
const mapStateToProps = state => {
return {
currentLayout: state.viewports.layout,
};
};
const mapDispatchToProps = dispatch => {
return {
// TODO: Change if layout switched becomes more complex
onChange: selectedCell => {
let viewports = [];
const rows = selectedCell.row + 1;
const columns = selectedCell.col + 1;
const numViewports = rows * columns;
for (let i = 0; i < numViewports; i++) {
viewports.push({
height: `${100 / rows}%`,
width: `${100 / columns}%`,
});
}
const layout = {
viewports,
};
dispatch(setLayout(layout));
},
};
};
const ConnectedLayoutButton = connect(
mapStateToProps,
mapDispatchToProps
)(LayoutButton);
export default ConnectedLayoutButton;
@@ -0,0 +1,36 @@
import { LayoutManager } from '@ohif/ui';
import { MODULE_TYPES } from '@ohif/core';
import { connect } from 'react-redux';
import { extensionManager } from './../App.js';
const mapStateToProps = state => {
const availableViewportModules = {};
const viewportModules = extensionManager.modules[MODULE_TYPES.VIEWPORT];
viewportModules.forEach(moduleDefinition => {
availableViewportModules[moduleDefinition.extensionId] =
moduleDefinition.module;
});
// TODO: Use something like state.plugins.defaultPlugin[MODULE_TYPES.VIEWPORT]
let defaultPlugin;
if (viewportModules.length) {
defaultPlugin = viewportModules[0].extensionId;
}
return {
layout: state.viewports.layout,
activeViewportIndex: state.viewports.activeViewportIndex,
// TODO: rename `availableViewportModules`
availablePlugins: availableViewportModules,
// TODO: rename `defaultViewportModule`
defaultPlugin,
};
};
const ConnectedLayoutManager = connect(
mapStateToProps,
null
)(LayoutManager);
export default ConnectedLayoutManager;
@@ -0,0 +1,103 @@
import OHIF from "@ohif/core";
import PluginSwitch from "./PluginSwitch.js";
import { commandsManager } from "./../App.js";
import { connect } from "react-redux";
const { setLayout } = OHIF.redux.actions;
const mapStateToProps = state => {
const { activeViewportIndex, layout, viewportSpecificData } = state.viewports;
return {
activeViewportIndex,
viewportSpecificData,
layout
};
};
const mapDispatchToProps = dispatch => {
return {
setLayout: data => {
dispatch(setLayout(data));
}
};
};
/*function setSingleLayoutData(originalArray, viewportIndex, data) {
const viewports = originalArray.slice();
const layoutData = Object.assign({}, viewports[viewportIndex], data);
viewports[viewportIndex] = layoutData;
return viewports;
}*/
const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
//const { activeViewportIndex, layout } = propsFromState;
//const { setLayout } = propsFromDispatch;
// TODO: Do not display certain options if the current display set
// cannot be displayed using these view types
const buttons = [
/*{
text: 'Acquired',
type: 'command',
icon: 'bars',
active: false,
onClick: () => {
console.warn('Original Acquisition');
const layoutData = setSingleLayoutData(
layout.viewports,
activeViewportIndex,
{ plugin: 'cornerstone' }
);
setLayout({ viewports: layoutData });
},
},
{
text: 'Axial',
icon: 'cube',
active: false,
onClick: () => {
commandsManager.runCommand('axial');
},
},
{
text: 'Sagittal',
icon: 'cube',
active: false,
onClick: () => {
commandsManager.runCommand('sagittal');
},
},
{
text: 'Coronal',
icon: 'cube',
active: false,
onClick: () => {
commandsManager.runCommand('coronal');
},
},*/
{
label: "2D MPR",
icon: "cube",
onClick: () => {
commandsManager.runCommand("mpr2d");
}
}
];
return {
buttons
};
};
const ConnectedPluginSwitch = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(PluginSwitch);
export default ConnectedPluginSwitch;
@@ -0,0 +1,40 @@
import { connect } from 'react-redux';
import { StudyBrowser } from '@ohif/ui';
import cloneDeep from 'lodash.clonedeep';
// TODO
// - Determine in which display set is active from Redux (activeViewportIndex and layout viewportData)
// - Pass in errors and stack loading progress from Redux
const mapStateToProps = (state, ownProps) => {
// If we know that the stack loading progress details have changed,
// we can try to update the component state so that the thumbnail
// progress bar is updated
const stackLoadingProgressMap = state.loading.progress;
const studiesWithLoadingData = cloneDeep(ownProps.studies);
studiesWithLoadingData.forEach(study => {
study.thumbnails.forEach(data => {
const { displaySetInstanceUid } = data;
const stackId = `StackProgress:${displaySetInstanceUid}`;
const stackProgressData = stackLoadingProgressMap[stackId];
let stackPercentComplete = 0;
if (stackProgressData) {
stackPercentComplete = stackProgressData.percentComplete;
}
data.stackPercentComplete = stackPercentComplete;
});
});
return {
studies: studiesWithLoadingData,
};
};
const ConnectedStudyBrowser = connect(
mapStateToProps,
null
)(StudyBrowser);
export default ConnectedStudyBrowser;
@@ -0,0 +1,26 @@
import { connect } from "react-redux";
import StudyLoadingMonitor from "../components/StudyLoadingMonitor.js";
import OHIF from "@ohif/core";
const {
setStudyLoadingProgress,
clearStudyLoadingProgress
} = OHIF.redux.actions;
const mapDispatchToProps = dispatch => {
return {
setStudyLoadingProgress: (progressId, progressData) => {
dispatch(setStudyLoadingProgress(progressId, progressData));
},
clearStudyLoadingProgress: progressId => {
dispatch(clearStudyLoadingProgress(progressId));
}
};
};
const ConnectedStudyLoadingMonitor = connect(
null,
mapDispatchToProps
)(StudyLoadingMonitor);
export default ConnectedStudyLoadingMonitor;
@@ -0,0 +1,34 @@
import { connect } from 'react-redux';
import ToolContextMenu from './ToolContextMenu';
const mapStateToProps = (state, ownProps) => {
if (!state.ui || !state.ui.contextMenu) {
return {
visible: false,
};
}
const { viewportIndex } = ownProps;
const toolContextMenuData = state.ui.contextMenu[viewportIndex];
let availableTools;
if (
state.extensions &&
state.extensions.cornerstone &&
state.extensions.cornerstone.availableTools
) {
availableTools = state.extensions.cornerstone.availableTools;
}
return {
...toolContextMenuData,
availableTools,
};
};
const ConnectedToolContextMenu = connect(
mapStateToProps,
null
)(ToolContextMenu);
export default ConnectedToolContextMenu;
@@ -0,0 +1,15 @@
// TODO: REPLACE THIS WITH A CONTEXT PROVIDER
// EVERYTHING IN `VIEWER.JS` COULD USE THIS FOR APPROPRIATE CONTEXT
import ToolbarRow from './ToolbarRow';
import { connect } from 'react-redux';
import { getActiveContexts } from './../store/layout/selectors.js';
const mapStateToProps = state => {
return {
activeContexts: getActiveContexts(state),
};
};
const ConnectedToolbarRow = connect(mapStateToProps)(ToolbarRow);
export default ConnectedToolbarRow;
@@ -0,0 +1,30 @@
import { connect } from "react-redux";
import Viewer from "./Viewer.js";
import OHIF from "@ohif/core";
const { setTimepoints, setMeasurements } = OHIF.redux.actions;
const mapStateToProps = (state, ownProps) => {
return {
viewports: state.viewports.viewportSpecificData,
activeViewportIndex: state.viewports.activeViewportIndex
};
};
const mapDispatchToProps = dispatch => {
return {
onTimepointsUpdated: timepoints => {
dispatch(setTimepoints(timepoints));
},
onMeasurementsUpdated: measurements => {
dispatch(setMeasurements(measurements));
}
};
};
const ConnectedViewer = connect(
mapStateToProps,
mapDispatchToProps
)(Viewer);
export default ConnectedViewer;
@@ -0,0 +1,37 @@
import OHIF from "@ohif/core";
import ViewerMain from "./ViewerMain";
import { connect } from "react-redux";
const {
setViewportSpecificData,
clearViewportSpecificData
} = OHIF.redux.actions;
const mapStateToProps = state => {
const { activeViewportIndex, layout, viewportSpecificData } = state.viewports;
return {
activeViewportIndex,
layout,
viewportSpecificData,
viewports: state.viewports
};
};
const mapDispatchToProps = dispatch => {
return {
setViewportSpecificData: (viewportIndex, data) => {
dispatch(setViewportSpecificData(viewportIndex, data));
},
clearViewportSpecificData: () => {
dispatch(clearViewportSpecificData());
}
};
};
const ConnectedViewerMain = connect(
mapStateToProps,
mapDispatchToProps
)(ViewerMain);
export default ConnectedViewerMain;
@@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import ViewerRetrieveStudyData from './ViewerRetrieveStudyData.js';
const isActive = a => a.active === true;
const mapStateToProps = state => {
const activeServer = state.servers.servers.find(isActive);
return {
server: activeServer,
};
};
const ConnectedViewerRetrieveStudyData = connect(
mapStateToProps,
null
)(ViewerRetrieveStudyData);
export default ConnectedViewerRetrieveStudyData;
@@ -0,0 +1,23 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import LabellingManager from '../components/Labelling/LabellingManager';
class LabellingOverlay extends Component {
static propTypes = {
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
visible: false,
};
render() {
if (!this.props.visible) {
return null;
}
return <LabellingManager {...this.props} />;
}
}
export default LabellingOverlay;
@@ -0,0 +1,3 @@
.icon-rotate-120 {
transform: rotate(120deg);
}
@@ -0,0 +1,22 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ExpandableToolMenu } from '@ohif/ui';
import './PluginSwitch.css';
class PluginSwitch extends Component {
static propTypes = {
buttons: PropTypes.array,
};
static defaultProps = {};
render() {
return (
<div className="PluginSwitch">
<ExpandableToolMenu buttons={this.props.buttons} text={'View'} />
</div>
);
}
}
export default PluginSwitch;
@@ -0,0 +1,34 @@
.ToolContextMenu {
position: absolute;
background-color: white;
border: 1px solid white;
border-radius: 5px;
z-index: 1000;
display: block;
width: 170px;
}
.ToolContextMenu > ul {
list-style-type: none;
padding-left: 0;
margin: 0;
}
.ToolContextMenu > ul > li > button {
padding: 10px;
font-size: 14px;
border: none;
color: #516873;
border-radius: 3px;
outline: none;
cursor: pointer;
background: none;
}
.ToolContextMenu > ul > li > button:hover {
color: #16202b;
}
.ToolContextMenu > ul > li > button:active {
color: #79f9fe;
}
@@ -0,0 +1,253 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cornerstone from 'cornerstone-core';
import cornerstoneTools from 'cornerstone-tools';
import getMeasurementLocationCallback from '../lib/getMeasurementLocationCallback';
import './ToolContextMenu.css';
const toolTypes = [
'Angle',
'Bidirectional',
'Length',
'FreehandMouse',
'EllipticalRoi',
'CircleRoi',
'RectangleRoi',
];
let defaultDropdownItems = [
{
actionType: 'Delete',
action: ({ nearbyToolData, eventData }) => {
const element = eventData.element;
cornerstoneTools.removeToolState(
element,
nearbyToolData.toolType,
nearbyToolData.tool
);
cornerstone.updateImage(element);
},
},
{
actionType: 'setLabel',
action: ({ nearbyToolData, eventData }) => {
const { tool } = nearbyToolData;
const options = {
skipAddLabelButton: true,
editLocation: true,
};
getMeasurementLocationCallback(eventData, tool, options);
},
},
{
actionType: 'setDescription',
action: ({ nearbyToolData, eventData }) => {
const { tool } = nearbyToolData;
const options = {
editDescriptionOnDialog: true,
};
getMeasurementLocationCallback(eventData, tool, options);
},
},
];
function getNearbyToolData(element, coords, toolTypes) {
const nearbyTool = {};
let pointNearTool = false;
toolTypes.forEach(toolType => {
const toolData = cornerstoneTools.getToolState(element, toolType);
if (!toolData) {
return;
}
toolData.data.forEach(function(data, index) {
// TODO: Fix this, it's ugly
let toolInterface = cornerstoneTools.getToolForElement(element, toolType);
if (!toolInterface) {
toolInterface = cornerstoneTools.getToolForElement(
element,
`${toolType}Tool`
);
}
if (!toolInterface) {
throw new Error('Tool not found.');
}
if (toolInterface.pointNearTool(element, data, coords)) {
pointNearTool = true;
nearbyTool.tool = data;
nearbyTool.index = index;
nearbyTool.toolType = toolType;
}
});
if (pointNearTool) {
return false;
}
});
return pointNearTool ? nearbyTool : undefined;
}
function getDropdownItems(eventData, isTouchEvent = false, availableTools) {
const nearbyToolData = getNearbyToolData(
eventData.element,
eventData.currentPoints.canvas,
toolTypes,
availableTools
);
// Annotate tools for touch events already have a press handle to edit it, has a better UX for deleting it
if (
isTouchEvent &&
nearbyToolData &&
nearbyToolData.toolType === 'arrowAnnotate'
) {
return;
}
let dropdownItems = [];
if (nearbyToolData) {
defaultDropdownItems.forEach(function(item) {
item.params = {
eventData,
nearbyToolData,
};
if (item.actionType === 'Delete') {
item.text = 'Delete measurement';
}
if (item.actionType === 'setLabel') {
item.text = 'Relabel';
}
if (item.actionType === 'setDescription') {
item.text = `${
nearbyToolData.tool.description ? 'Edit' : 'Add'
} Description`;
}
dropdownItems.push(item);
});
}
return dropdownItems;
}
class ToolContextMenu extends Component {
static propTypes = {
isTouchEvent: PropTypes.bool.isRequired,
eventData: PropTypes.object,
onClose: PropTypes.func,
availableTools: PropTypes.array,
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
visible: true,
isTouchEvent: false,
};
constructor(props) {
super(props);
this.mainElement = React.createRef();
}
render() {
if (!this.props.eventData) {
return null;
}
const { isTouchEvent, eventData, availableTools } = this.props;
const dropdownItems = getDropdownItems(
eventData,
isTouchEvent,
availableTools
);
// Skip if there is no dropdown item
if (!dropdownItems.length) {
return '';
}
const dropdownComponents = dropdownItems.map(item => {
const itemOnClick = event => {
item.action(item.params);
if (this.props.onClose) {
this.props.onClose();
}
};
return (
<li key={item.actionType}>
<button className="form-action" onClick={itemOnClick}>
<span key={item.actionType}>{item.text}</span>
</button>
</li>
);
});
const position = {
top: `${eventData.currentPoints.canvas.y}px`,
left: `${eventData.currentPoints.canvas.x}px`,
};
return (
<div className="ToolContextMenu" style={position} ref={this.mainElement}>
<ul className="bounded">{dropdownComponents}</ul>
</div>
);
}
componentDidMount = () => {
if (this.mainElement.current) {
this.updateElementPosition();
}
};
componentDidUpdate = () => {
if (this.mainElement.current) {
this.updateElementPosition();
}
};
updateElementPosition = () => {
const {
offsetParent,
offsetTop,
offsetHeight,
offsetWidth,
offsetLeft,
} = this.mainElement.current;
const { eventData } = this.props;
if (offsetTop + offsetHeight > offsetParent.offsetHeight) {
const offBoundPixels =
offsetTop + offsetHeight - offsetParent.offsetHeight;
const top = eventData.currentPoints.canvas.y - offBoundPixels;
this.mainElement.current.style.top = `${top > 0 ? top : 0}px`;
}
if (offsetLeft + offsetWidth > offsetParent.offsetWidth) {
const offBoundPixels =
offsetLeft + offsetWidth - offsetParent.offsetWidth;
const left = eventData.currentPoints.canvas.x - offBoundPixels;
this.mainElement.current.style.left = `${left > 0 ? left : 0}px`;
}
};
}
export default ToolContextMenu;
@@ -0,0 +1,17 @@
.ToolbarRow {
border-bottom: var(--ui-border-thickness) solid var(--ui-border-color);
flex: 0 0 auto;
height: var(--toolbar-height);
padding-left: 1rem;
padding-right: 1rem;
position: relative;
transition: height 300ms ease;
width: 100%;
display: flex;
align-items: center;
}
ToolbarRow.expanded {
height: calc(var(--toolbar-height) + var(--toolbar-drawer-height));
}
@@ -0,0 +1,250 @@
import './ToolbarRow.css';
import React, { Component } from 'react';
import {
ExpandableToolMenu,
RoundedButtonGroup,
ToolbarButton,
} from '@ohif/ui';
import { commandsManager, extensionManager } from './../App.js';
import ConnectedCineDialog from './ConnectedCineDialog';
import ConnectedLayoutButton from './ConnectedLayoutButton';
import ConnectedPluginSwitch from './ConnectedPluginSwitch.js';
import { MODULE_TYPES } from '@ohif/core';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
class ToolbarRow extends Component {
// TODO: Simplify these? isOpen can be computed if we say "any" value for selected,
// closed if selected is null/undefined
static propTypes = {
isLeftSidePanelOpen: PropTypes.bool.isRequired,
isRightSidePanelOpen: PropTypes.bool.isRequired,
selectedLeftSidePanel: PropTypes.string.isRequired,
selectedRightSidePanel: PropTypes.string.isRequired,
handleSidePanelChange: PropTypes.func,
activeContexts: PropTypes.arrayOf(PropTypes.string).isRequired,
};
constructor(props) {
super(props);
const toolbarButtonDefinitions = _getVisibleToolbarButtons.call(this);
// TODO:
// If it's a tool that can be active... Mark it as active?
// - Tools that are on/off?
// - Tools that can be bound to multiple buttons?
// Normal ToolbarButtons...
// Just how high do we need to hoist this state?
// Why ToolbarRow instead of just Toolbar? Do we have any others?
this.state = {
toolbarButtons: toolbarButtonDefinitions,
activeButtons: [],
isCineDialogOpen: false,
};
this._handleBuiltIn = _handleBuiltIn.bind(this);
const panelModules = extensionManager.modules[MODULE_TYPES.PANEL];
this.buttonGroups = {
left: [
// TODO: This should come from extensions, instead of being baked in
{
value: 'studies',
icon: 'th-large',
bottomLabel: this.props.t('Series'),
},
],
right: [],
};
panelModules.forEach(panelExtension => {
const panelModule = panelExtension.module;
const defaultContexts = Array.from(panelModule.defaultContext);
// MENU OPTIONS
panelModule.menuOptions.forEach(menuOption => {
const contexts = Array.from(menuOption.context || defaultContexts);
const activeContextIncludesAnyPanelContexts = this.props.activeContexts.some(
actx => contexts.includes(actx)
);
if (activeContextIncludesAnyPanelContexts) {
const menuOptionEntry = {
value: menuOption.target,
icon: menuOption.icon,
bottomLabel: menuOption.label,
};
const from = menuOption.from || 'right';
this.buttonGroups[from].push(menuOptionEntry);
}
});
});
}
componentDidUpdate(prevProps) {
const activeContextsChanged =
prevProps.activeContexts !== this.props.activeContexts;
if (activeContextsChanged) {
this.setState({
toolbarButtons: _getVisibleToolbarButtons.call(this),
});
}
}
render() {
const buttonComponents = _getButtonComponents.call(
this,
this.state.toolbarButtons,
this.state.activeButtons
);
const cineDialogContainerStyle = {
display: this.state.isCineDialogOpen ? 'block' : 'none',
position: 'absolute',
top: '82px',
zIndex: 999,
};
const onPress = (side, value) => {
this.props.handleSidePanelChange(side, value);
};
const onPressLeft = onPress.bind(this, 'left');
const onPressRight = onPress.bind(this, 'right');
return (
<>
<div className="ToolbarRow">
<div className="pull-left m-t-1 p-y-1" style={{ padding: '10px' }}>
<RoundedButtonGroup
options={this.buttonGroups.left}
value={this.props.selectedLeftSidePanel || ''}
onValueChanged={onPressLeft}
/>
</div>
{buttonComponents}
<ConnectedLayoutButton />
<ConnectedPluginSwitch />
<div
className="pull-right m-t-1 rm-x-1"
style={{ marginLeft: 'auto' }}
>
{this.buttonGroups.right.length && (
<RoundedButtonGroup
options={this.buttonGroups.right}
value={this.props.selectedRightSidePanel || ''}
onValueChanged={onPressRight}
/>
)}
</div>
</div>
<div className="CineDialogContainer" style={cineDialogContainerStyle}>
<ConnectedCineDialog />
</div>
</>
);
}
}
/**
* Determine which extension buttons should be showing, if they're
* active, and what their onClick behavior should be.
*/
function _getButtonComponents(toolbarButtons, activeButtons) {
return toolbarButtons.map((button, index) => {
let activeCommand = undefined;
if (button.buttons && button.buttons.length) {
// Iterate over button definitions and update `onClick` behavior
const childButtons = button.buttons.map(childButton => {
childButton.onClick = _handleToolbarButtonClick.bind(this, childButton);
if (activeButtons.indexOf(childButton.id) > -1) {
activeCommand = childButton.id;
}
return childButton;
});
return (
<ExpandableToolMenu
key={button.id}
text={button.label}
icon={button.icon}
buttons={childButtons}
activeCommand={activeCommand}
/>
);
}
return (
<ToolbarButton
key={button.id}
label={button.label}
icon={button.icon}
onClick={_handleToolbarButtonClick.bind(this, button)}
isActive={activeButtons.includes(button.id)}
/>
);
});
}
/**
* A handy way for us to handle different button types. IE. firing commands for
* buttons, or initiation built in behavior.
*
* @param {*} button
* @param {*} evt
* @param {*} props
*/
function _handleToolbarButtonClick(button, evt, props) {
if (button.commandName) {
const options = Object.assign({ evt }, button.commandOptions);
commandsManager.runCommand(button.commandName, options);
}
// TODO: Use Types ENUM
// TODO: We can update this to be a `getter` on the extension to query
// For the active tools after we apply our updates?
if (button.type === 'setToolActive') {
this.setState({
activeButtons: [button.id],
});
} else if (button.type === 'builtIn') {
this._handleBuiltIn(button.options);
}
}
/**
*
*/
function _getVisibleToolbarButtons() {
const toolbarModules = extensionManager.modules[MODULE_TYPES.TOOLBAR];
const toolbarButtonDefinitions = [];
toolbarModules.forEach(extension => {
const { definitions, defaultContext } = extension.module;
definitions.forEach(definition => {
const context = definition.context || defaultContext;
if (this.props.activeContexts.includes(context)) {
toolbarButtonDefinitions.push(definition);
}
});
});
return toolbarButtonDefinitions;
}
function _handleBuiltIn({ behavior } = {}) {
if (behavior === 'CINE') {
this.setState({
isCineDialogOpen: !this.state.isCineDialogOpen,
});
}
}
export default withTranslation('Common')(ToolbarRow);
@@ -0,0 +1,10 @@
#viewer {
background-color: black;
width: 100%;
}
.loadingTextDiv {
color: var(--text-secondary-color);
font-size: 30px;
}
@@ -0,0 +1,377 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { MODULE_TYPES } from '@ohif/core';
import OHIF from '@ohif/core';
import moment from 'moment';
import WhiteLabellingContext from '../WhiteLabellingContext.js';
import ConnectedHeader from './ConnectedHeader.js';
import ConnectedToolbarRow from './ConnectedToolbarRow.js';
import ConnectedLabellingOverlay from './ConnectedLabellingOverlay';
import ConnectedStudyBrowser from './ConnectedStudyBrowser.js';
import ConnectedViewerMain from './ConnectedViewerMain.js';
import SidePanel from './../components/SidePanel.js';
import { extensionManager } from './../App.js';
import UserManagerContext from '../UserManagerContext';
import './Viewer.css';
/**
* Inits OHIF Hanging Protocol's onReady.
* It waits for OHIF Hanging Protocol to be ready to instantiate the ProtocolEngine
* Hanging Protocol will use OHIF LayoutManager to render viewports properly
*/
/*const initHangingProtocol = () => {
// When Hanging Protocol is ready
HP.ProtocolStore.onReady(() => {
// Gets all StudyMetadata objects: necessary for Hanging Protocol to access study metadata
const studyMetadataList = OHIF.viewer.StudyMetadataList.all();
// Instantiate StudyMetadataSource: necessary for Hanging Protocol to get study metadata
const studyMetadataSource = new OHIF.studies.classes.OHIFStudyMetadataSource();
// Get prior studies map
const studyPriorsMap = OHIF.studylist.functions.getStudyPriorsMap(studyMetadataList);
// Creates Protocol Engine object with required arguments
const ProtocolEngine = new HP.ProtocolEngine(layoutManager, studyMetadataList, studyPriorsMap, studyMetadataSource);
// Sets up Hanging Protocol engine
HP.setEngine(ProtocolEngine);
});
};*/
/*const viewportUtils = OHIF.viewerbase.viewportUtils;
OHIF.viewer.functionList = {
toggleCineDialog: viewportUtils.toggleCineDialog,
toggleCinePlay: viewportUtils.toggleCinePlay,
clearTools: viewportUtils.clearTools,
resetViewport: viewportUtils.resetViewport,
invert: viewportUtils.invert
};*/
class Viewer extends Component {
static propTypes = {
studies: PropTypes.array,
studyInstanceUids: PropTypes.array,
onTimepointsUpdated: PropTypes.func,
onMeasurementsUpdated: PropTypes.func,
// window.store.getState().viewports.viewportSpecificData
viewports: PropTypes.object.isRequired,
// window.store.getState().viewports.activeViewportIndex
activeViewportIndex: PropTypes.number.isRequired,
};
constructor(props) {
super(props);
OHIF.measurements.MeasurementApi.setConfiguration({
dataExchange: {
retrieve: this.retrieveMeasurements,
store: this.storeMeasurements,
},
});
OHIF.measurements.TimepointApi.setConfiguration({
dataExchange: {
retrieve: this.retrieveTimepoints,
store: this.storeTimepoints,
remove: this.removeTimepoint,
update: this.updateTimepoint,
disassociate: this.disassociateStudy,
},
});
}
state = {
isLeftSidePanelOpen: true,
isRightSidePanelOpen: false,
selectedRightSidePanel: '',
selectedLeftSidePanel: 'studies', // TODO: Don't hardcode this
thumbnails: [],
};
retrieveMeasurements = (patientId, timepointIds) => {
OHIF.log.info('retrieveMeasurements');
// TODO: Retrieve the measurements from the latest available SR
return Promise.resolve();
};
storeMeasurements = (measurementData, timepointIds) => {
OHIF.log.info('storeMeasurements');
// TODO: Store the measurements into a new SR sent to the active server
return Promise.resolve();
};
retrieveTimepoints = filter => {
OHIF.log.info('retrieveTimepoints');
// Get the earliest and latest study date
let earliestDate = new Date().toISOString();
let latestDate = new Date().toISOString();
if (this.props.studies) {
latestDate = new Date('1000-01-01').toISOString();
this.props.studies.forEach(study => {
const studyDate = moment(study.studyDate, 'YYYYMMDD').toISOString();
if (studyDate < earliestDate) {
earliestDate = studyDate;
}
if (studyDate > latestDate) {
latestDate = studyDate;
}
});
}
// Return a generic timepoint
return Promise.resolve([
{
timepointType: 'baseline',
timepointId: 'TimepointId',
studyInstanceUids: this.props.studyInstanceUids,
patientId: filter.patientId,
earliestDate,
latestDate,
isLocked: false,
},
]);
};
storeTimepoints = timepointData => {
OHIF.log.info('storeTimepoints');
return Promise.resolve();
};
updateTimepoint = (timepointData, query) => {
OHIF.log.info('updateTimepoint');
return Promise.resolve();
};
removeTimepoint = timepointId => {
OHIF.log.info('removeTimepoint');
return Promise.resolve();
};
disassociateStudy = (timepointIds, studyInstanceUid) => {
OHIF.log.info('disassociateStudy');
return Promise.resolve();
};
onTimepointsUpdated = timepoints => {
if (this.props.onTimepointsUpdated) {
this.props.onTimepointsUpdated(timepoints);
}
};
onMeasurementsUpdated = measurements => {
if (this.props.onMeasurementsUpdated) {
this.props.onMeasurementsUpdated(measurements);
}
};
componentDidMount() {
const { studies } = this.props;
const { TimepointApi, MeasurementApi } = OHIF.measurements;
const currentTimepointId = 'TimepointId';
const timepointApi = new TimepointApi(currentTimepointId, {
onTimepointsUpdated: this.onTimepointsUpdated,
});
const measurementApi = new MeasurementApi(timepointApi, {
onMeasurementsUpdated: this.onMeasurementsUpdated,
});
this.currentTimepointId = currentTimepointId;
this.timepointApi = timepointApi;
this.measurementApi = measurementApi;
if (studies) {
const patientId = studies[0] && studies[0].patientId;
timepointApi.retrieveTimepoints({ patientId });
measurementApi.retrieveMeasurements(patientId, [currentTimepointId]);
this.setState({
thumbnails: _mapStudiesToThumbnails(studies),
});
}
}
componentDidUpdate(prevProps) {
if (this.props.studies !== prevProps.studies) {
const { studies } = this.props;
const patientId = studies[0] && studies[0].patientId;
const currentTimepointId = this.currentTimepointId;
this.timepointApi.retrieveTimepoints({ patientId });
this.measurementApi.retrieveMeasurements(patientId, [currentTimepointId]);
this.setState({
thumbnails: _mapStudiesToThumbnails(studies),
});
}
}
render() {
let VisiblePanelLeft, VisiblePanelRight;
const panelExtensions = extensionManager.modules[MODULE_TYPES.PANEL];
panelExtensions.forEach(panelExt => {
panelExt.module.components.forEach(comp => {
if (comp.id === this.state.selectedRightSidePanel) {
VisiblePanelRight = comp.component;
} else if (comp.id === this.state.selectedLeftSidePanel) {
VisiblePanelLeft = comp.component;
}
});
});
return (
<>
{/* HEADER */}
<WhiteLabellingContext.Consumer>
{whiteLabelling => (
<UserManagerContext.Consumer>
{userManager => (
<ConnectedHeader home={false} userManager={userManager}>
{whiteLabelling.logoComponent}
</ConnectedHeader>
)}
</UserManagerContext.Consumer>
)}
</WhiteLabellingContext.Consumer>
{/* TOOLBAR */}
<ConnectedToolbarRow
isLeftSidePanelOpen={this.state.isLeftSidePanelOpen}
isRightSidePanelOpen={this.state.isRightSidePanelOpen}
selectedLeftSidePanel={
this.state.isLeftSidePanelOpen
? this.state.selectedLeftSidePanel
: ''
}
selectedRightSidePanel={
this.state.isRightSidePanelOpen
? this.state.selectedRightSidePanel
: ''
}
handleSidePanelChange={(side, selectedPanel) => {
const sideClicked = side && side[0].toUpperCase() + side.slice(1);
const openKey = `is${sideClicked}SidePanelOpen`;
const selectedKey = `selected${sideClicked}SidePanel`;
const updatedState = Object.assign({}, this.state);
const isOpen = updatedState[openKey];
const prevSelectedPanel = updatedState[selectedKey];
// RoundedButtonGroup returns `null` if selected button is clicked
const isSameSelectedPanel =
prevSelectedPanel === selectedPanel || selectedPanel === null;
updatedState[selectedKey] = selectedPanel || prevSelectedPanel;
const isClosedOrShouldClose = !isOpen || isSameSelectedPanel;
if (isClosedOrShouldClose) {
updatedState[openKey] = !updatedState[openKey];
}
this.setState(updatedState);
}}
/>
{/*<ConnectedStudyLoadingMonitor studies={this.props.studies} />*/}
{/*<StudyPrefetcher studies={this.props.studies} />*/}
{/* VIEWPORTS + SIDEPANELS */}
<div className="FlexboxLayout">
{/* LEFT */}
<SidePanel from="left" isOpen={this.state.isLeftSidePanelOpen}>
{VisiblePanelLeft ? (
<VisiblePanelLeft
viewports={this.props.viewports}
activeIndex={this.props.activeViewportIndex}
/>
) : (
<ConnectedStudyBrowser studies={this.state.thumbnails} />
)}
</SidePanel>
{/* MAIN */}
<div className={classNames('main-content')}>
<ConnectedViewerMain studies={this.props.studies} />
</div>
{/* RIGHT */}
<SidePanel from="right" isOpen={this.state.isRightSidePanelOpen}>
{VisiblePanelRight && (
<VisiblePanelRight
viewports={this.props.viewports}
activeIndex={this.props.activeViewportIndex}
/>
)}
</SidePanel>
</div>
<ConnectedLabellingOverlay />
</>
);
}
}
export default Viewer;
/**
* What types are these? Why do we have "mapping" dropped in here instead of in
* a mapping layer?
*
* TODO[react]:
* - Add sorting of display sets
* - Add showStackLoadingProgressBar option
*
* @param {Study[]} studies
* @param {DisplaySet[]} studies[].displaySets
*/
const _mapStudiesToThumbnails = function(studies) {
return studies.map(study => {
const { studyInstanceUid } = study;
const thumbnails = study.displaySets.map(displaySet => {
const {
displaySetInstanceUid,
seriesDescription,
seriesNumber,
instanceNumber,
numImageFrames,
} = displaySet;
let imageId;
let altImageText;
if (displaySet.modality && displaySet.modality === 'SEG') {
// TODO: We want to replace this with a thumbnail showing
// the segmentation map on the image, but this is easier
// and better than what we have right now.
altImageText = 'SEG';
} else if (displaySet.images && displaySet.images.length) {
const imageIndex = Math.floor(displaySet.images.length / 2);
imageId = displaySet.images[imageIndex].getImageId();
} else {
altImageText = displaySet.modality ? displaySet.modality : 'UN';
}
return {
imageId,
altImageText,
displaySetInstanceUid,
seriesDescription,
seriesNumber,
instanceNumber,
numImageFrames,
};
});
return {
studyInstanceUid,
thumbnails,
};
});
};
@@ -0,0 +1,15 @@
.drag-drop-instructions {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.drag-drop-instructions h3 {
color: var(--active-color);
}
.drag-drop-instructions h4 {
color: var(--text-secondary-color);
}
@@ -0,0 +1,117 @@
import React, { Component } from 'react';
import { metadata, utils } from '@ohif/core';
import ConnectedViewer from './ConnectedViewer.js';
import PropTypes from 'prop-types';
import { extensionManager } from './../App.js';
import Dropzone from 'react-dropzone';
import filesToStudies from '../lib/filesToStudies';
import './ViewerLocalFileData.css';
import { withTranslation } from 'react-i18next';
const { OHIFStudyMetadata } = metadata;
const { studyMetadataManager, updateMetaDataManager } = utils;
class ViewerLocalFileData extends Component {
static propTypes = {
studies: PropTypes.array,
};
state = {
studies: null,
loading: false,
error: null,
};
updateStudies = studies => {
// Render the viewer when the data is ready
studyMetadataManager.purge();
// Map studies to new format, update metadata manager?
const updatedStudies = studies.map(study => {
const studyMetadata = new OHIFStudyMetadata(
study,
study.studyInstanceUid
);
const sopClassHandlerModules =
extensionManager.modules['sopClassHandlerModule'];
study.displaySets =
study.displaySets ||
studyMetadata.createDisplaySets(sopClassHandlerModules);
studyMetadata.setDisplaySets(study.displaySets);
// Updates WADO-RS metaDataManager
updateMetaDataManager(study);
studyMetadataManager.add(studyMetadata);
return study;
});
this.setState({
studies: updatedStudies,
});
};
render() {
const onDrop = async acceptedFiles => {
this.setState({ loading: true });
const studies = await filesToStudies(acceptedFiles);
const updatedStudies = this.updateStudies(studies);
if (!updatedStudies) {
return;
}
this.setState({ studies: updatedStudies, loading: false });
};
if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>;
}
return (
<Dropzone onDrop={onDrop}>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()} style={{ width: '100%', height: '100%' }}>
{this.state.studies ? (
<ConnectedViewer
studies={this.state.studies}
studyInstanceUids={
this.state.studies &&
this.state.studies.map(a => a.studyInstanceUid)
}
/>
) : (
<div className={'drag-drop-instructions'}>
<div className={'drag-drop-contents'}>
{this.state.loading ? (
<h3>{this.props.t('Loading...')}</h3>
) : (
<>
<h3>
{this.props.t(
'Drag and Drop DICOM files here to load them in the Viewer'
)}
</h3>
<h4>
{this.props.t(
"Or click to load the browser's file selector"
)}
</h4>
</>
)}
</div>
<input {...getInputProps()} style={{ display: 'none' }} />
</div>
)}
</div>
)}
</Dropzone>
);
}
}
export default withTranslation('Common')(ViewerLocalFileData);
@@ -0,0 +1,21 @@
#viewer {
height: calc(100% - var(--top-bar-height));
}
#viewer > .loadingTextDiv {
color: var(--text-secondary-color);
font-size: 30px;
height: 100%;
line-height: calc(100% - var(--top-bar-height));
}
.ViewerMain {
width: 100%;
height: 100%;
transition: all 0.3s ease;
}
.ViewerMain > div {
width: 100%;
height: 100%;
}
@@ -0,0 +1,182 @@
import './ViewerMain.css';
import { Component } from 'react';
import ConnectedLayoutManager from './ConnectedLayoutManager.js';
import ConnectedToolContextMenu from './ConnectedToolContextMenu.js';
import PropTypes from 'prop-types';
import React from 'react';
class ViewerMain extends Component {
static propTypes = {
activeViewportIndex: PropTypes.number.isRequired,
studies: PropTypes.array,
viewportSpecificData: PropTypes.object.isRequired,
layout: PropTypes.object.isRequired,
setViewportSpecificData: PropTypes.func.isRequired,
clearViewportSpecificData: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
displaySets: [],
};
this.cachedViewportData = {};
}
getDisplaySets(studies) {
const displaySets = [];
studies.forEach(study => {
study.displaySets.forEach(dSet => {
if (!dSet.plugin) {
dSet.plugin = 'cornerstone';
}
displaySets.push(dSet);
});
});
return displaySets;
}
findDisplaySet(studies, studyInstanceUid, displaySetInstanceUid) {
const study = studies.find(study => {
return study.studyInstanceUid === studyInstanceUid;
});
if (!study) {
return;
}
return study.displaySets.find(displaySet => {
return displaySet.displaySetInstanceUid === displaySetInstanceUid;
});
}
componentDidMount() {
// Add beforeUnload event handler to check for unsaved changes
//window.addEventListener('beforeunload', unloadHandlers.beforeUnload);
// Get all the display sets for the viewer studies
if (this.props.studies) {
const displaySets = this.getDisplaySets(this.props.studies);
this.setState({
displaySets,
});
}
}
componentDidUpdate(prevProps) {
if (this.props.studies !== prevProps.studies) {
const displaySets = this.getDisplaySets(this.props.studies);
this.setState({
displaySets,
});
}
}
getViewportData = () => {
const viewportData = [];
const { layout, viewportSpecificData } = this.props;
for (
let viewportIndex = 0;
viewportIndex < layout.viewports.length;
viewportIndex++
) {
let displaySet = viewportSpecificData[viewportIndex];
// Use the cached display set in viewport if the new one is empty
if (displaySet && !displaySet.displaySetInstanceUid) {
displaySet = this.cachedViewportData[viewportIndex];
}
if (
displaySet &&
displaySet.studyInstanceUid &&
displaySet.displaySetInstanceUid
) {
// Get missing fields from original display set
const originalDisplaySet = this.findDisplaySet(
this.props.studies,
displaySet.studyInstanceUid,
displaySet.displaySetInstanceUid
);
viewportData.push(Object.assign({}, originalDisplaySet, displaySet));
} else {
// If the viewport is empty, get one available in study
const { displaySets } = this.state;
displaySet = displaySets.find(
ds =>
!viewportData.some(
v => v.displaySetInstanceUid === ds.displaySetInstanceUid
)
);
viewportData.push(Object.assign({}, displaySet));
}
}
this.cachedViewportData = viewportData;
return viewportData;
};
setViewportData = ({ viewportIndex, item }) => {
const displaySet = this.findDisplaySet(
this.props.studies,
item.studyInstanceUid,
item.displaySetInstanceUid
);
this.props.setViewportSpecificData(viewportIndex, displaySet);
};
render() {
return (
<div className="ViewerMain">
{this.state.displaySets.length && (
<ConnectedLayoutManager
studies={this.props.studies}
viewportData={this.getViewportData()}
setViewportData={this.setViewportData}
>
{/* Children to add to each viewport that support children */}
<ConnectedToolContextMenu />
</ConnectedLayoutManager>
)}
</div>
);
}
componentWillUnmount() {
// Clear the entire viewport specific data
const { viewportSpecificData } = this.props;
Object.keys(viewportSpecificData).forEach(viewportIndex => {
this.props.clearViewportSpecificData(viewportIndex);
});
// TODO: These don't have to be viewer specific?
// Could qualify for other routes?
// hotkeys.destroy();
// Remove beforeUnload event handler...
//window.removeEventListener('beforeunload', unloadHandlers.beforeUnload);
// Destroy the synchronizer used to update reference lines
//OHIF.viewer.updateImageSynchronizer.destroy();
// TODO: Instruct all plugins to clean up themselves
//
// Clear references to all stacks in the StackManager
//StackManager.clearStacks();
// @TypeSafeStudies
// Clears OHIF.viewer.Studies collection
//OHIF.viewer.Studies.removeAll();
// @TypeSafeStudies
// Clears OHIF.viewer.StudyMetadataList collection
//OHIF.viewer.StudyMetadataList.removeAll();
}
}
export default ViewerMain;
@@ -0,0 +1,89 @@
import React, { Component } from "react";
import { metadata, studies, utils } from "@ohif/core";
import ConnectedViewer from "./ConnectedViewer.js";
import PropTypes from "prop-types";
import { extensionManager } from "./../App.js";
const { OHIFStudyMetadata } = metadata;
const { retrieveStudiesMetadata } = studies;
const { studyMetadataManager, updateMetaDataManager } = utils;
class ViewerRetrieveStudyData extends Component {
static propTypes = {
studyInstanceUids: PropTypes.array.isRequired,
seriesInstanceUids: PropTypes.array,
server: PropTypes.object
};
state = {
studies: null,
error: null
};
async componentDidMount() {
// TODO: Avoid using timepoints here
//const params = { studyInstanceUids, seriesInstanceUids, timepointId, timepointsFilter={} };
const { studyInstanceUids, seriesInstanceUids, server } = this.props;
try {
const studies = await retrieveStudiesMetadata(
server,
studyInstanceUids,
seriesInstanceUids
);
// Render the viewer when the data is ready
// TODO: CLEAR THIS SOMEWHERE ELSE
studyMetadataManager.purge();
// Map studies to new format, update metadata manager?
const updatedStudies = studies.map(study => {
const studyMetadata = new OHIFStudyMetadata(
study,
study.studyInstanceUid
);
const sopClassHandlerModules =
extensionManager.modules["sopClassHandlerModule"];
study.displaySets =
study.displaySets ||
studyMetadata.createDisplaySets(sopClassHandlerModules);
studyMetadata.setDisplaySets(study.displaySets);
// Updates WADO-RS metaDataManager
updateMetaDataManager(study);
studyMetadataManager.add(studyMetadata);
return study;
});
this.setState({
studies: updatedStudies
});
} catch (err) {
this.setState({
error: true
});
// TODO: Handle gracefully instead of throwing?
throw new Error(err);
}
}
render() {
if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>;
}
return (
<ConnectedViewer
studies={this.state.studies}
studyInstanceUids={this.props.studyInstanceUids}
/>
);
}
}
export default ViewerRetrieveStudyData;
@@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import DicomFileUploaderModal from './DicomFileUploaderModal.js';
import OHIF from '@ohif/core';
const isActive = a => a.active === true;
const mapStateToProps = state => {
const activeServer = state.servers.servers.find(isActive);
// TODO: Not sure I like this approach since it means we are recreating
// this function every time redux changes
const retrieveAuthHeaderFunction = () =>
OHIF.DICOMWeb.getAuthorizationHeader(activeServer);
return {
retrieveAuthHeaderFunction,
url: activeServer && activeServer.qidoRoot,
};
};
const ConnectedDicomFileUploader = connect(
mapStateToProps,
null
)(DicomFileUploaderModal);
export default ConnectedDicomFileUploader;
@@ -0,0 +1,32 @@
import { connect } from 'react-redux';
import DicomStorePickerModal from './DicomStorePickerModal.js';
const isActive = a => a.active === true;
const mapStateToProps = state => {
const activeServer = state.servers.servers.find(isActive);
return {
user: state.oidc && state.oidc.user,
url: activeServer && activeServer.qidoRoot,
};
};
const mapDispatchToProps = dispatch => {
return {
setServers: servers => {
const action = {
type: 'SET_SERVERS',
servers,
};
dispatch(action);
},
};
};
const ConnectedDicomStorePicker = connect(
mapStateToProps,
mapDispatchToProps
)(DicomStorePickerModal);
export default ConnectedDicomStorePicker;
@@ -0,0 +1,55 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import api from './api/GoogleCloudApi';
import DatasetsList from './DatasetsList';
import './googleCloud.css';
export default class DatasetPicker extends Component {
state = {
error: null,
loading: true,
datasets: [],
};
static propTypes = {
project: PropTypes.object,
location: PropTypes.object,
onSelect: PropTypes.func,
accessToken: PropTypes.string,
};
async componentDidMount() {
api.setAccessToken(this.props.accessToken);
const response = await api.loadDatasets(
this.props.project.projectId,
this.props.location.locationId
);
if (response.isError) {
this.setState({
error: response.message,
});
return;
}
this.setState({
datasets: response.data.datasets || [],
loading: false,
});
}
render() {
const { datasets, loading, error } = this.state;
const { onSelect } = this.props;
return (
<DatasetsList
datasets={datasets}
loading={loading}
error={error}
onSelect={onSelect}
/>
);
}
}
@@ -0,0 +1,158 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import DicomStorePicker from './DicomStorePicker';
import DatasetPicker from './DatasetPicker';
import ProjectPicker from './ProjectPicker';
import LocationPicker from './LocationPicker';
import GoogleCloudApi from './api/GoogleCloudApi';
import './googleCloud.css';
class DatasetSelector extends Component {
state = {
project: null,
location: null,
dataset: null,
unloading: false,
};
static propTypes = {
id: PropTypes.string,
event: PropTypes.string,
user: PropTypes.object,
canClose: PropTypes.string,
setServers: PropTypes.func.isRequired,
};
onProjectSelect = project => {
this.setState({
project,
});
};
onLocationSelect = location => {
this.setState({
location,
});
};
onDatasetSelect = dataset => {
this.setState({
dataset,
});
};
onProjectClick = () => {
this.setState({
dataset: null,
location: null,
project: null,
});
};
onLocationClick = () => {
this.setState({
dataset: null,
location: null,
});
};
onDatasetClick = () => {
this.setState({
dataset: null,
});
};
onDicomStoreSelect = dicomStoreJson => {
const dicomStore = dicomStoreJson.name;
const parts = dicomStore.split('/');
const result = {
wadoUriRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`,
qidoRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`,
wadoRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`,
project: parts[1],
location: parts[3],
dataset: parts[5],
dicomStore: parts[7],
};
this.props.setServers(result);
};
render() {
const accessToken = this.props.user.access_token;
const { project, location, dataset } = this.state;
const {
onProjectClick,
onLocationClick,
onDatasetClick,
onProjectSelect,
onLocationSelect,
onDatasetSelect,
onDicomStoreSelect,
} = this;
let projectBreadcrumbs = (
<div className="gcp-picker--path">
<span>{this.props.t('Select a Project')}</span>
</div>
);
if (project) {
projectBreadcrumbs = (
<div className="gcp-picker--path">
<span onClick={onProjectClick}>{project.name}</span>
{project && location && (
<span onClick={onLocationClick}>
{' '}
-> {location.name.split('/')[3]}
</span>
)}
{project && location && dataset && (
<span onClick={onDatasetClick}>
{' '}
-> {dataset.name.split('/')[5]}
</span>
)}
</div>
);
}
return (
<>
{projectBreadcrumbs}
{!project && (
<ProjectPicker
accessToken={accessToken}
onSelect={onProjectSelect}
/>
)}
{project && !location && (
<LocationPicker
accessToken={accessToken}
project={project}
onSelect={onLocationSelect}
/>
)}
{project && location && !dataset && (
<DatasetPicker
accessToken={accessToken}
project={project}
location={location}
onSelect={onDatasetSelect}
/>
)}
{project && location && dataset && (
<DicomStorePicker
accessToken={accessToken}
dataset={dataset}
onSelect={onDicomStoreSelect}
/>
)}
</>
);
}
}
export default withTranslation('Common')(DatasetSelector);
@@ -0,0 +1,80 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
class DatasetsList extends Component {
state = {
search: '',
};
static propTypes = {
datasets: PropTypes.array,
loading: PropTypes.bool,
error: PropTypes.string,
onSelect: PropTypes.func,
};
static defaultProps = {
loading: true,
};
renderTableRow = dataset => {
return (
<tr
key={dataset.name}
className={
this.state.highlightedItem === dataset.name
? 'noselect active'
: 'noselect'
}
onMouseEnter={() => {
this.onHighlightItem(dataset.name);
}}
onClick={() => {
this.props.onSelect(dataset);
}}
>
<td>{dataset.name.split('/')[5]}</td>
</tr>
);
};
onHighlightItem(dataset) {
this.setState({ highlightedItem: dataset });
}
render() {
if (this.props.error) {
return <p>{this.props.error}</p>;
}
const loadingIcon = (
<Icon name="circle-notch" className="loading-icon-spin loading-icon" />
);
if (this.props.loading) {
return loadingIcon;
}
const body = (
<tbody id="DatasetList">
{this.props.datasets.map(this.renderTableRow)}
</tbody>
);
return (
<table id="tblDatasetList" className="gcp-table table noselect">
<thead>
<tr>
<th>{this.props.t('Dataset')}</th>
</tr>
</thead>
{this.props.datasets && body}
</table>
);
}
}
export default withTranslation('Common')(DatasetsList);
@@ -0,0 +1,49 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-bootstrap-modal';
import DicomUploader from './DicomUploader';
import { withTranslation } from 'react-i18next';
class DicomFileUploaderModal extends Component {
static propTypes = {
url: PropTypes.string,
retrieveAuthHeaderFunction: PropTypes.func,
onClose: PropTypes.func,
};
state = {
uploaded: false,
};
render() {
if (!this.props.url) {
return null;
}
return (
<Modal
show={this.props.isOpen}
onHide={this.props.onClose}
aria-labelledby="ModalHeader"
className="modal fade themed in"
backdrop={false}
size={'md'}
keyboard={true}
>
<Modal.Header closeButton>
<Modal.Title>
{this.props.t('Upload DICOM Files')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<DicomUploader
url={this.props.url}
retrieveAuthHeaderFunction={this.props.retrieveAuthHeaderFunction}
/>
</Modal.Body>
</Modal>
);
}
}
export default withTranslation('Common')(DicomFileUploaderModal);
@@ -0,0 +1,78 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
class DicomStoreList extends Component {
state = {
search: '',
};
static propTypes = {
stores: PropTypes.array,
loading: PropTypes.bool.isRequired,
error: PropTypes.string,
onSelect: PropTypes.func,
};
static defaultProps = {
loading: true,
};
renderTableRow = store => {
return (
<tr
key={store.name}
className={
this.state.highlightedItem === store.name
? 'noselect active'
: 'noselect'
}
onMouseEnter={() => {
this.onHighlightItem(store.name);
}}
onClick={() => {
this.props.onSelect(store);
}}
>
<td className="project">{store.name.split('/')[7]}</td>
</tr>
);
};
onHighlightItem(store) {
this.setState({ highlightedItem: store });
}
render() {
if (this.props.error) {
return <p>{this.props.error}</p>;
}
const loadingIcon = (
<Icon name="circle-notch" className="loading-icon-spin loading-icon" />
);
if (this.props.loading) {
return loadingIcon;
}
const body = (
<tbody id="StoreList">{this.props.stores.map(this.renderTableRow)}</tbody>
);
return (
<table id="tblStoreList" className="gcp-table table noselect">
<thead>
<tr>
<th>{this.props.t('DICOM Store')}</th>
</tr>
</thead>
{this.props.stores && body}
</table>
);
}
}
export default withTranslation('Common')(DicomStoreList);
@@ -0,0 +1,53 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import api from './api/GoogleCloudApi';
import DicomStoreList from './DicomStoreList';
import './googleCloud.css';
export default class DicomStorePicker extends Component {
state = {
error: null,
loading: true,
stores: [],
locations: [],
};
static propTypes = {
dataset: PropTypes.object,
onSelect: PropTypes.func,
accessToken: PropTypes.string.isRequired
};
async componentDidMount() {
api.setAccessToken(this.props.accessToken);
const response = await api.loadDicomStores(this.props.dataset.name);
if (response.isError) {
this.setState({
error: response.message,
});
return;
}
this.setState({
stores: response.data.dicomStores || [],
loading: false,
});
}
render() {
const { stores, loading, error } = this.state;
const { onSelect } = this.props;
return (
<DicomStoreList
stores={stores}
loading={loading}
error={error}
onSelect={onSelect}
/>
);
}
}
@@ -0,0 +1,68 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-bootstrap-modal';
import DatasetSelector from './DatasetSelector';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
class DicomStorePickerModal extends Component {
static propTypes = {
url: PropTypes.string,
user: PropTypes.object.isRequired,
setServers: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func,
};
static defaultProps = {
isOpen: false,
};
handleEvent = data => {
const servers = [
{
name: data.dicomStore,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
qidoSupportsIncludeField: false,
type: 'dicomWeb',
qidoRoot: data.qidoRoot,
wadoRoot: data.wadoRoot,
wadoUriRoot: data.wadoUriRoot,
active: true,
supportsFuzzyMatching: false,
},
];
this.props.setServers(servers);
};
render() {
return (
<Modal
show={this.props.isOpen}
onHide={this.props.onClose}
aria-labelledby="ModalHeader"
className="modal fade themed in"
backdrop={false}
size={'md'}
keyboard={true}
>
<Modal.Header closeButton>
<Modal.Title>
{this.props.t('Google Cloud Healthcare API')}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<DatasetSelector
setServers={this.handleEvent}
user={this.props.user}
url={this.props.url}
/>
</Modal.Body>
</Modal>
);
}
}
export default withTranslation('Common')(DicomStorePickerModal);
@@ -0,0 +1,20 @@
.dicom-uploader .button {
float: left;
margin: 5px;
}
.invisible-input {
position: absolute;
display: none;
z-index: -1000;
max-width: 0 !important;
max-height: 0 !important;
}
.dicom-uploader .table-header {
color: #ffffff;
font-size: 16px;
font-weight: 28px;
text-align: left;
margin: 20px auto;
}
@@ -0,0 +1,188 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { formatFileSize } from './utils/helpers';
import CancellationToken from './utils/CancellationToken';
import dicomUploader from './api/DicomUploadService';
import './DicomUploader.css';
export default class DicomUploader extends Component {
state = {
status: 'Upload',
isCancelled: false,
errorsCount: 0,
files: null,
uploadedVolume: null,
wholeVolumeStr: null,
isFilesListHidden: true,
timeLeft: null,
uploadedList: null,
totalCount: 0,
successfullyUploadedCount: 0,
lastFile: '',
uploadContext: null, // this is probably not needed, but we use this variable to distinguish between different downloads
};
static propTypes = {
id: PropTypes.string,
event: PropTypes.string,
url: PropTypes.string,
retrieveAuthHeaderFunction: PropTypes.func,
};
filesLeft() {
return (
this.state.uploadedList.length + ' of ' + this.state.totalCount + ' files'
);
}
volumeLeft() {
let left = formatFileSize(this.state.uploadedVolume);
return left + ' of ' + this.state.wholeVolumeStr;
}
percents() {
return parseInt(
(100 * this.state.uploadedList.length) /
Object.keys(this.state.files).length
);
}
isFinished() {
return (
this.state.isCancelled ||
Object.keys(this.state.files).length === this.state.uploadedList.length
);
}
errorsMessage() {
const errors = this.state.errorsCount === 1 ? ' error' : ' errors';
return (
this.state.errorsCount + errors + ' while uploading, click for more info'
);
}
uploadFiles = files => {
const filesArray = Array.from(files.target.files);
const filesDict = {};
filesArray.forEach((file, i) => {
const fileDesc = {
id: i,
name: file.name,
path: file.webkitRelativePath || file.name,
size: file.size,
error: null,
processed: false,
processedInUI: false,
};
filesDict[i] = fileDesc;
file.fileId = i;
});
const wholeVolume = filesArray.map(f => f.size).reduce((a, b) => a + b);
const uploadContext = Math.random();
this.setState({
status: 'Uploading...',
files: filesDict,
uploadedList: [],
uploadedVolume: 0,
lastFile: filesArray[0].name,
totalCount: filesArray.length,
wholeVolumeStr: formatFileSize(wholeVolume),
uploadContext: uploadContext,
cancellationToken: new CancellationToken(),
});
const cancellationToken = new CancellationToken();
const uploadCallback = (fileId, error) =>
uploadContext === this.state.uploadContext &&
this.uploadCallback.call(this, fileId, error);
dicomUploader.setRetrieveAuthHeaderFunction(this.props.retrieveAuthHeaderFunction);
dicomUploader.smartUpload(
files.target.files,
this.props.url,
uploadCallback,
cancellationToken
);
};
uploadCallback(fileId, error) {
const file = this.state.files[fileId];
file.processed = true;
if (!error) {
let uploadedVolume = this.state.uploadedVolume + file.size;
this.setState({ uploadedVolume });
} else {
file.error = error;
this.setState({ errorsCount: this.state.errorsCount + 1 });
}
this.setState({ lastFile: file.name });
let uploadedList = this.state.uploadedList;
uploadedList.push(file);
this.setState({ uploadedList });
}
renderTableRow = file => {
let error = null;
if (file.error !== null) {
error = <p style={{ color: 'red' }}>{file.error}</p>;
}
return (
<tr key={file.id}>
<td className="project">
{file.name} {error}
</td>
</tr>
);
};
render() {
if (this.state.files === null) {
return (
<div className="dicom-uploader">
<div className="button">
<label htmlFor="file">
<img src="./assets/Button_File.svg" alt="upload file"></img>
</label>
<input
className="invisible-input"
onChange={this.uploadFiles}
type="file"
id="file"
multiple
/>
</div>
<div className="button">
<label htmlFor="folder">
<img src="./assets/Button_Folder.svg" alt="upload folder"></img>
</label>
<input
className="invisible-input"
type="file"
onChange={this.uploadFiles}
id="folder"
webkitdirectory="true"
mozdirectory="true"
multiple
/>
</div>
</div>
);
}
return (
<table id="tblProjectList" className="table noselect">
<thead>
<tr>
<th className="table-header">
{this.percents()}% {this.filesLeft()}
</th>
</tr>
</thead>
<tbody id="ProjectList">
{this.state.uploadedList.map(this.renderTableRow)}
</tbody>
</table>
);
}
}
@@ -0,0 +1,51 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import api from './api/GoogleCloudApi';
import LocationsList from './LocationsList';
import './googleCloud.css';
export default class LocationPicker extends Component {
state = {
error: null,
loading: true,
locations: [],
};
static propTypes = {
project: PropTypes.object,
onSelect: PropTypes.func,
accessToken: PropTypes.string,
};
async componentDidMount() {
api.setAccessToken(this.props.accessToken);
const response = await api.loadLocations(this.props.project.projectId);
if (response.isError) {
this.setState({
error: response.message,
});
return;
}
this.setState({
locations: response.data.locations || [],
loading: false,
});
}
render() {
const { locations, loading, error } = this.state;
const { onSelect } = this.props;
return (
<LocationsList
locations={locations}
loading={loading}
error={error}
onSelect={onSelect}
/>
);
}
}
@@ -0,0 +1,80 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
class LocationsList extends Component {
state = {
search: '',
};
static propTypes = {
locations: PropTypes.array,
loading: PropTypes.bool.isRequired,
error: PropTypes.string,
onSelect: PropTypes.func,
};
static defaultProps = {
loading: true,
};
renderTableRow = location => {
return (
<tr
key={location.locationId}
className={
this.state.highlightedItem === location.locationId
? 'noselect active'
: 'noselect'
}
onMouseEnter={() => {
this.onHighlightItem(location.locationId);
}}
onClick={() => {
this.props.onSelect(location);
}}
>
<td>{location.name.split('/')[3]}</td>
</tr>
);
};
onHighlightItem(locationId) {
this.setState({ highlightedItem: locationId });
}
render() {
if (this.props.error) {
return <p>{this.props.error}</p>;
}
const loadingIcon = (
<Icon name="circle-notch" className="loading-icon-spin loading-icon" />
);
if (this.props.loading) {
return loadingIcon;
}
const body = (
<tbody id="LocationList">
{this.props.locations.map(this.renderTableRow)}
</tbody>
);
return (
<table id="tblLocationList" className="gcp-table table noselect">
<thead>
<tr>
<th>{this.props.t('Location')}</th>
</tr>
</thead>
{this.props.locations && body}
</table>
);
}
}
export default withTranslation('Common')(LocationsList);
@@ -0,0 +1,49 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import api from './api/GoogleCloudApi';
import ProjectsList from './ProjectsList';
import './googleCloud.css';
export default class ProjectPicker extends Component {
state = {
error: null,
loading: true,
projects: [],
};
static propTypes = {
onSelect: PropTypes.func,
accessToken: PropTypes.string,
};
async componentDidMount() {
api.setAccessToken(this.props.accessToken);
const response = await api.loadProjects();
if (response.isError) {
this.setState({
error: response.message,
});
return;
}
this.setState({
projects: response.data.projects || [],
loading: false,
});
}
render() {
const { projects, loading, error } = this.state;
const { onSelect } = this.props;
return (
<ProjectsList
projects={projects}
loading={loading}
error={error}
onSelect={onSelect}
/>
);
}
}
@@ -0,0 +1,85 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import './googleCloud.css';
import { withTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
class ProjectsList extends Component {
state = {
search: '',
highlightedItem: null,
};
static propTypes = {
projects: PropTypes.array,
loading: PropTypes.bool.isRequired,
error: PropTypes.string,
onSelect: PropTypes.func.isRequired,
t: PropTypes.func,
};
static defaultProps = {
loading: true,
};
renderTableRow = project => {
return (
<tr
key={project.projectId}
className={
this.state.highlightedItem === project.projectId
? 'noselect active'
: 'noselect'
}
onMouseEnter={() => {
this.onHighlightItem(project.projectId);
}}
onClick={() => {
this.onHighlightItem(project.projectId);
this.props.onSelect(project);
}}
>
<td>{project.name}</td>
<td>{project.projectId}</td>
</tr>
);
};
onHighlightItem(project) {
this.setState({ highlightedItem: project });
}
render() {
if (this.props.error) {
return <p>{this.props.error}</p>;
}
const loadingIcon = (
<Icon name="circle-notch" className="loading-icon-spin loading-icon" />
);
if (this.props.loading) {
return loadingIcon;
}
const body = (
<tbody id="ProjectList">
{this.props.projects.map(this.renderTableRow)}
</tbody>
);
return (
<table id="tblProjectList" className="gcp-table table noselect">
<thead>
<tr>
<th>{this.props.t('Project')}</th>
<th>{this.props.t('ID')}</th>
</tr>
</thead>
{this.props.projects && body}
</table>
);
}
}
export default withTranslation('Common')(ProjectsList);
@@ -0,0 +1,88 @@
import { httpErrorToStr, checkDicomFile } from '../utils/helpers';
import { api } from 'dicomweb-client';
class DicomUploadService {
async smartUpload(files, url, uploadCallback, cancellationToken) {
const CHUNK_SIZE = 1; // Only one file per request is supported so far
const MAX_PARALLEL_JOBS = 50; // FIXME: tune MAX_PARALLEL_JOBS number
let filesArray = Array.from(files);
if (filesArray.length === 0) {
throw new Error('No files were provided.');
}
let parallelJobsCount = Math.min(filesArray.length, MAX_PARALLEL_JOBS);
let completed = false;
const processJob = async (resolve, reject) => {
while (filesArray.length > 0) {
if (cancellationToken.get()) return;
let chunk = filesArray.slice(0, CHUNK_SIZE);
filesArray = filesArray.slice(CHUNK_SIZE);
let error = null;
try {
if (chunk.length > 1) throw new Error('Not implemented');
if (chunk.length === 1)
await this.simpleUpload(chunk[0], url);
} catch (err) {
// It looks like a stupid bug of Babel that err is not an actual Exception object
error = httpErrorToStr(err);
}
chunk.forEach(file => uploadCallback(file.fileId, error));
if (!completed && filesArray.length === 0) {
completed = true;
resolve();
return;
}
}
};
await new Promise(resolve => {
for (let i = 0; i < parallelJobsCount; i++) {
processJob(resolve);
}
});
}
async simpleUpload(file, url) {
const client = this.getClient(url);
const loadedFile = await this.readFile(file);
const content = loadedFile.content;
if (!checkDicomFile(content))
throw new Error('This is not a valid DICOM file.');
await client.storeInstances({ datasets: [content] });
}
readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
name: file.name,
size: file.size,
type: file.type,
content: reader.result,
});
};
reader.onerror = error => reject(error);
reader.readAsArrayBuffer(file);
});
}
setRetrieveAuthHeaderFunction(func) {
this.retrieveAuthHeaderFunc = func;
}
getClient(url) {
const headers = this.retrieveAuthHeaderFunc();
// TODO: a bit weird we are creating a new dicomweb client instance for every upload
return new api.DICOMwebClient({
url,
headers
});
}
}
export default new DicomUploadService();
@@ -0,0 +1,94 @@
class GoogleCloudApi {
setAccessToken(accessToken) {
if (!accessToken) console.error('Access token is empty');
this.accessToken = accessToken;
}
get fetchConfig() {
if (!this.accessToken) throw new Error('OIDC access_token is not set');
return {
method: 'GET',
headers: {
Authorization: 'Bearer ' + this.accessToken,
},
};
}
get urlBase() {
return `https://healthcare.googleapis.com/v1beta1`;
}
get urlBaseProject() {
return this.urlBase + `/projects`;
}
async doRequest(urlStr, config = {}, params = {}) {
const url = new URL(urlStr);
let data = null;
url.search = new URLSearchParams(params);
try {
const response = await fetch(url, { ...this.fetchConfig, config });
try {
data = await response.json();
} catch (err) {}
if (response.status >= 200 && response.status < 300 && data != null) {
if (data.nextPageToken != null) {
params.pageToken = data.nextPageToken;
let subPage = await this.doRequest(urlStr, config, params);
for (let key in data) {
if (data.hasOwnProperty(key)) {
data[key] = data[key].concat(subPage.data[key]);
}
}
}
return {
isError: false,
status: response.status,
data,
};
} else {
return {
isError: true,
status: response.status,
message:
(data && data.error && data.error.message) || 'Unknown error',
};
}
} catch (err) {
if (data && data.error) {
return {
isError: true,
status: err.status,
message: err.response.data.error.message || 'Unspecified error',
};
}
return {
isError: true,
message: (err && err.message) || 'Oops! Something went wrong',
};
}
}
async loadProjects() {
return this.doRequest(
'https://cloudresourcemanager.googleapis.com/v1/projects'
);
}
async loadLocations(projectId) {
return this.doRequest(`${this.urlBaseProject}/${projectId}/locations`);
}
async loadDatasets(projectId, locationId) {
return this.doRequest(
`${this.urlBaseProject}/${projectId}/locations/${locationId}/datasets`
);
}
async loadDicomStores(dataset) {
return this.doRequest(`${this.urlBase}/${dataset}/dicomStores`);
}
}
export default new GoogleCloudApi();
@@ -0,0 +1,143 @@
.loading-icon-spin {
animation: spin 2s linear infinite;
}
.loading-icon {
font-size: 30px;
margin: 20px 0;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.gcp-table {
width: 100%;
max-width: 100%;
margin-bottom: 20px;
border-spacing: 0;
border-collapse: collapse;
}
.gcp-table > tr {
height: 20px;
}
.gcp-table > thead {
white-space: nowrap;
}
.gcp-table > thead tr th {
padding: 0;
border-bottom: 1px solid var(--ui-border-color-active);
width: 100%;
text-align: left;
border-top: 0;
}
.gcp-table > tbody tr {
padding: 5px;
background-color: var(--ui-gray-darker);
}
.gcp-table > tbody tr:nth-child(even) {
background-color: var(--ui-gray-dark);
}
.gcp-table > tbody tr td {
padding: 8px;
height: $body-cell-height;
line-height: $body-cell-height;
color: var(--table-text-primary-color);
font-weight: 300;
border-top: 1px solid var(--ui-gray-lighter);
border-bottom: 1px solid var(--ui-gray-lighter);
white-space: nowrap;
}
.gcp-table > tbody tr td.emptyCell {
color: #516873;
}
.gcp-table > tbody tr td.emptyValue {
color: var(--ui-gray-light);
}
.gcp-table > tbody tr:hover,
.gcp-table > tbody tr:active,
.gcp-table > tbody tr.active {
background-color: var(--table-hover-color);
color: var(--text-primary-color);
}
.gcp-table > tbody tr:hover td,
.gcp-table > tbody tr:active td,
.gcp-table > tbody tr.active td {
border-top: 1px solid var(--ui-gray-lighter);
border-bottom: 1px solid var(--ui-gray-lighter);
background-color: var(--table-hover-color);
}
.gcp-files-selector {
display: flex;
justify-content: space-between;
}
.gcp-files-selector__btn-dir,
.gcp-files-selector__btn-file {
height: 80px !important;
width: 228px !important;
font-size: 14px;
border-radius: 8px;
margin: 0 !important;
}
.gcp-files-selector__btn-dir {
background-image: url('/assets/Button_Folder.svg');
}
.gcp-files-selector__btn-file {
background-image: url('/assets/Button_File.svg');
}
.gcp-window {
background-color: #000000;
}
.gcp-hidden {
display: none;
}
.gcp-picker {
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
width: 650px;
height: 600px;
background-color: #161a1f;
padding: 20px 30px;
position: relative;
}
.gcp-dicom-picker__exit {
cursor: pointer;
position: absolute;
top: 30px;
right: 30px;
width: 14px !important;
height: 14px !important;
filter: opacity(0.5);
}
.gcp-dicom-picker__exit:hover {
filter: opacity(1);
}
.gcp-picker--btn {
margin: auto;
}
.gcp-picker--title {
color: #ffffff;
font-size: 24px;
text-align: center;
margin: 20px auto;
}
.gcp-picker--path {
color: #ffffff;
font-size: 16px;
font-weight: 28px;
text-align: left;
margin: 20px auto;
}
@@ -0,0 +1,13 @@
export default class CancellationToken {
constructor() {
this.cancelled = false;
}
get() {
return this.cancelled;
}
set(value) {
this.cancelled = value;
}
}
@@ -0,0 +1,35 @@
export function formatFileSize(size) {
if (size === 0) return '0 B';
const n = Math.floor(Math.log(size) / Math.log(1024));
return (
(size / Math.pow(1024, n)).toFixed(2) * 1 +
' ' +
['B', 'kB', 'MB', 'GB', 'TB'][n]
);
}
export function httpErrorToStr(error) {
if (!error) return 'Unknown error';
if (error.response) {
try {
const jsonResponse = JSON.parse(error.response);
if (
jsonResponse.error &&
jsonResponse.error.code &&
jsonResponse.error.message
)
return jsonResponse.error.code + ' - ' + jsonResponse.error.message;
} catch (err) {
return error.response;
}
}
return error.message || 'Unknown error.';
}
/* eslint-disable */
export function checkDicomFile(arrayBuffer) {
if (arrayBuffer.length <= 132) return false;
const arr = new Uint8Array(arrayBuffer.slice(128, 132));
// bytes from 128 to 132 must be "DICM"
return Array.from('DICM').every((char, i) => char.charCodeAt(0) === arr[i]);
}
+15
View File
@@ -0,0 +1,15 @@
<head>
<meta charset="utf-8">
<title>OHIF Viewer</title>
<meta name="description" content="Open Health Imaging Foundation DICOM Viewer">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1,maximum-scale=1,user-scalable=no">
<link rel="shortcut icon" type="image/png" href="/images/logo.png" sizes="16x16 32x32 64x64">
<!--Best display for mobile devices-->
<meta http-equiv="cleartype" content="on">
<meta name="MobileOptimized" content="320">
<meta name="HandheldFriendly" content="True">
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body>
<div id="root"></div>
</body>
+20
View File
@@ -0,0 +1,20 @@
/**
* Entry point index.js for UMD packaging
*/
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
function installViewer(props, containerId = 'root', callback) {
const container = document.getElementById(containerId);
if (!container) {
throw new Error(
"No root element found to install viewer. Please add a <div> with the id 'root', or pass a DOM element into the installViewer function."
);
}
return ReactDOM.render(<App {...props} />, container, callback);
}
export { App, installViewer };
+52
View File
@@ -0,0 +1,52 @@
/**
* Entry point for development and production PWA builds.
* Packaged (NPM) builds go through `index-umd.js`
*/
import App from './App.js';
import React from 'react';
import ReactDOM from 'react-dom';
/**
* EXTENSIONS
* =================
*
* Importing and modifying the extensions our app uses HERE allows us to leverage
* tree shaking and a few other niceties. However, by including them here they become
* "baked in" to the published application.
*
* Depending on your use case/needs, you may want to consider not adding any extensions
* by default HERE, and instead provide them via the configuration specified at
* `window.config.extensions`, or by using the exported `App` component, and passing
* in your extensions as props.
*/
import OHIFVTKExtension from '@ohif/extension-vtk';
import OHIFDicomHtmlExtension from '@ohif/extension-dicom-html';
import OHIFDicomMicroscopyExtension from '@ohif/extension-dicom-microscopy';
import OHIFDicomPDFExtension from '@ohif/extension-dicom-pdf';
// Default Settings
let config = {};
const appDefaults = {
routerBasename: '/',
relativeWebWorkerScriptsPath: '',
};
console.log(OHIFVTKExtension);
if (window) {
config = window.config || {};
config.extensions = [
OHIFVTKExtension,
OHIFDicomHtmlExtension,
OHIFDicomMicroscopyExtension,
OHIFDicomPDFExtension,
];
}
const appProps = Object.assign({}, appDefaults, config);
// Create App
const app = React.createElement(App, appProps, null);
// Render
ReactDOM.render(app, document.getElementById('root'));
@@ -0,0 +1,27 @@
import Hammer from 'hammerjs';
import cornerstone from 'cornerstone-core';
import cornerstoneMath from 'cornerstone-math';
import cornerstoneTools from 'cornerstone-tools';
export default function(configuration = {}) {
// For debugging
window.cornerstoneTools = cornerstoneTools;
cornerstoneTools.external.cornerstone = cornerstone;
cornerstoneTools.external.Hammer = Hammer;
cornerstoneTools.external.cornerstoneMath = cornerstoneMath;
cornerstoneTools.init(configuration);
// Set the tool font and font size
// context.font = "[style] [variant] [weight] [size]/[line height] [font family]";
const fontFamily =
'Roboto, OpenSans, HelveticaNeue-Light, Helvetica Neue Light, Helvetica Neue, Helvetica, Arial, Lucida Grande, sans-serif';
cornerstoneTools.textStyle.setFont(`16px ${fontFamily}`);
// Tool styles/colors
cornerstoneTools.toolStyle.setToolWidth(2);
cornerstoneTools.toolColors.setToolColor('rgb(255, 255, 0)');
cornerstoneTools.toolColors.setActiveColor('rgb(0, 255, 0)');
cornerstoneTools.store.state.touchProximity = 40;
}
+160
View File
@@ -0,0 +1,160 @@
import cornerstone from 'cornerstone-core';
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
import * as dcmjs from 'dcmjs';
function datasetsToStudies(datasets) {
const StudyInstanceUIDs = new Set();
datasets.forEach(ds => {
StudyInstanceUIDs.add(ds.StudyInstanceUID);
});
const studies = [];
Array.from(StudyInstanceUIDs).forEach(studyInstanceUid => {
const studyDatasets = datasets.filter(
ds => ds.StudyInstanceUID === studyInstanceUid
);
const firstDataset = studyDatasets[0];
const study = {
studyInstanceUid: firstDataset.StudyInstanceUID,
studyDate: firstDataset.StudyDate,
studyTime: firstDataset.StudyTime,
accessionNumber: firstDataset.AccessionNumber,
referringPhysicianName: firstDataset.ReferringPhysicianName,
patientName: firstDataset.PatientName,
patientId: firstDataset.PatientID,
patientBirthdate: firstDataset.PatientBirthDate,
patientSex: firstDataset.PatientSex,
studyId: firstDataset.StudyID,
studyDescription: firstDataset.StudyDescription,
//numberOfStudyRelatedSeries: DICOMWeb.getString(study['00201206']),
//numberOfStudyRelatedInstances: DICOMWeb.getString(study['00201208']),
// modality: DICOMWeb.getString(study['00080060']),
// modalitiesInStudy: DICOMWeb.getString(study['00080061']),
//modalities:
seriesList: [],
};
const SeriesInstanceUIDs = new Set();
studyDatasets.forEach(ds => {
SeriesInstanceUIDs.add(ds.SeriesInstanceUID);
});
Array.from(SeriesInstanceUIDs).forEach(seriesInstanceUid => {
const seriesDatasets = studyDatasets.filter(
ds => ds.SeriesInstanceUID === seriesInstanceUid
);
const SOPInstanceUIDs = new Set();
seriesDatasets.forEach(ds => {
SOPInstanceUIDs.add(ds.SOPInstanceUID);
study.seriesList.push({
seriesInstanceUid: ds.SeriesInstanceUID,
seriesDescription: ds.SeriesDescription,
seriesNumber: ds.SeriesNumber,
instances: [],
});
});
Array.from(SOPInstanceUIDs).forEach(sopInstanceUid => {
const instance = seriesDatasets.find(
a => a.SOPInstanceUID === sopInstanceUid
);
const series = study.seriesList.find(
a => a.seriesInstanceUid === seriesInstanceUid
);
series.instances.push({
sopInstanceUid: instance.SOPInstanceUID,
sopClassUid: instance.SOPClassUID,
rows: instance.Rows,
columns: instance.Columns,
numberOfFrames: instance.NumberOfFrames,
instanceNumber: instance.InstanceNumber,
getImageId: () => instance.imageId, // TODO: Change getImageId to check for instance.imageId property first.
/*imageType: DICOMWeb.getString(instance['00080008']),
modality: DICOMWeb.getString(instance['00080060']),
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
pixelSpacing: DICOMWeb.getString(instance['00280030']),
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
bitsStored: DICOMWeb.getNumber(instance['00280101']),
highBit: DICOMWeb.getNumber(instance['00280102']),
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
windowCenter: DICOMWeb.getString(instance['00281050']),
windowWidth: DICOMWeb.getString(instance['00281051']),
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
rescaleType: DICOMWeb.getNumber(instance['00281054']),
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
laterality: DICOMWeb.getString(instance['00200062']),
viewPosition: DICOMWeb.getString(instance['00185101']),
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
frameTime: DICOMWeb.getNumber(instance['00181063']),
frameTimeVector: parseFloatArray(
DICOMWeb.getString(instance['00181065'])
),
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
derivationDescription: DICOMWeb.getString(instance['00282111']),
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
echoNumber: DICOMWeb.getString(instance['00180086']),
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
wadouri: WADOProxy.convertURL(wadouri, server),
wadorsuri: WADOProxy.convertURL(wadorsuri, server),*/
});
});
});
studies.push(study);
});
console.warn(studies);
return studies;
}
export default async function filesToStudies(files) {
const imagePromises = files.map(file => {
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(file);
return cornerstone
.loadAndCacheImage(imageId)
.catch(error => console.warn(error));
});
const images = await Promise.all(imagePromises);
const datasets = [];
images.forEach(image => {
if (!image || !image.data) {
return;
}
const arrayBuffer = image.data.byteArray.buffer;
const dicomData = dcmjs.data.DicomMessage.readFile(arrayBuffer);
const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(
dicomData.dict
);
dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset(
dicomData.meta
);
dataset.imageId = image.imageId;
datasets.push(dataset);
});
return datasetsToStudies(datasets);
}
@@ -0,0 +1,33 @@
import cornerstoneTools from 'cornerstone-tools';
import updateTableWithNewMeasurementData from './updateTableWithNewMeasurementData';
export default function getMeasurementLocationCallback(
eventData,
tool,
options
) {
const { toolType } = tool;
const { element } = eventData;
const doneCallback = updateTableWithNewMeasurementData;
const ToolInstance = cornerstoneTools.getToolForElement(element, toolType);
if (
!ToolInstance ||
!ToolInstance.configuration ||
!ToolInstance.configuration.getMeasurementLocationCallback
) {
console.warn(
'Tool instance configuration is missing: getMeasurementLocationCallback'
);
return;
}
ToolInstance.configuration.getMeasurementLocationCallback(
tool,
eventData,
doneCallback,
options
);
}
@@ -0,0 +1,29 @@
import OHIF from "@ohif/core";
import cornerstone from "cornerstone-core";
export default function updateTableWithNewMeasurementData({
toolType,
measurementNumber,
location,
description
}) {
// Update all measurements by measurement number
const measurementApi = OHIF.measurements.MeasurementApi.Instance;
const measurements = measurementApi.tools[toolType].filter(
m => m.measurementNumber === measurementNumber
);
measurements.forEach(measurement => {
measurement.location = location;
measurement.description = description;
measurementApi.updateMeasurement(measurement.toolType, measurement);
});
measurementApi.syncMeasurementsAndToolData();
// Update images in all active viewports
cornerstone.getEnabledElements().forEach(enabledElement => {
cornerstone.updateImage(enabledElement.element);
});
}
+46
View File
@@ -0,0 +1,46 @@
export default function bounding(elementRef, currentPosition = {}) {
if (!elementRef.current) {
return;
}
const currentElement = elementRef.current;
const {
offsetParent,
offsetTop,
offsetHeight,
offsetLeft,
offsetWidth,
} = currentElement;
let top = currentPosition.top || offsetTop;
let left = currentPosition.left || offsetLeft;
if (!offsetParent) {
return;
}
let maxHeight = `${offsetParent.offsetHeight}px`;
if (offsetHeight + top > offsetParent.offsetHeight) {
top = top - (offsetHeight + top - offsetParent.offsetHeight);
if (top < 0) {
top = 0;
}
}
if (left + offsetWidth > offsetParent.offsetWidth) {
left = offsetParent.offsetWidth - offsetWidth;
if (left < 0) {
left = 0;
}
}
if (maxHeight && currentElement.style.maxHeight !== maxHeight) {
currentElement.style.maxHeight = maxHeight;
}
if (currentElement.style.top !== `${top}px`) {
currentElement.style.top = `${top}px`;
}
if (currentElement.style.left !== `${left}px`) {
currentElement.style.left = `${left}px`;
}
}
@@ -0,0 +1,32 @@
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
import { CallbackComponent } from 'redux-oidc';
class CallbackPage extends Component {
static propTypes = {
userManager: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
render() {
return (
<CallbackComponent
userManager={this.props.userManager}
successCallback={() => {
const pathname = sessionStorage.getItem('ohif-redirect-to');
this.props.history.push(pathname);
}}
errorCallback={error => {
//this.props.history.push("/");
throw new Error(error);
}}
>
<div>Redirecting...</div>
</CallbackComponent>
);
}
}
export default withRouter(CallbackPage);
@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import queryString from 'query-string';
import ConnectedViewerRetrieveStudyData from '../connectedComponents/ConnectedViewerRetrieveStudyData.js';
function decodeStudyUids(studyUids) {
const decodedData = window.atob(studyUids);
return decodedData.split(';');
}
function getQueryParameters(location) {
if (location) {
return queryString.parse(location.search);
}
return {};
}
function IHEInvokeImageDisplay({ location }) {
const {
// patientID,
requestType,
studyUID,
} = getQueryParameters(location);
switch (requestType) {
case 'STUDY':
return (
<ConnectedViewerRetrieveStudyData
studyInstanceUids={studyUID.split(';')}
/>
);
case 'STUDYBASE64':
return (
<ConnectedViewerRetrieveStudyData
studyInstanceUids={decodeStudyUids(studyUID)}
/>
);
case 'PATIENT':
// TODO: connect this to the StudyList when we have the filter parameters set up
// return <StudyList patientUids={patientID.split(';')} />;
return '';
default:
// TODO: Figure out what to do here, this won't work because StudyList expects studies
// return <StudyList />;
return '';
}
}
IHEInvokeImageDisplay.propTypes = {
location: PropTypes.shape({
search: PropTypes.string,
}).isRequired,
};
export default withRouter(IHEInvokeImageDisplay);
+9
View File
@@ -0,0 +1,9 @@
.not-found {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: white;
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import './NotFound.css';
import { Link } from 'react-router-dom';
export default function NotFound() {
return (
<div className={'not-found'}>
<div>
<h4>Sorry, this page does not exist.</h4>
<h5>
<Link to={'/'}>Go back to the Study List</Link>
</h5>
</div>
</div>
);
}
@@ -0,0 +1,142 @@
import React, { Component } from "react";
import { log, metadata, studies, utils } from "@ohif/core";
import PropTypes from "prop-types";
import Viewer from "../connectedComponents/Viewer";
import { extensionManager } from "./../App.js";
import qs from "querystring";
const { OHIFStudyMetadata } = metadata;
const { retrieveStudiesMetadata } = studies;
const { studyMetadataManager, updateMetaDataManager } = utils;
class StandaloneRouting extends Component {
state = {
studies: null,
error: null
};
static propTypes = {
location: PropTypes.object,
store: PropTypes.object
};
static parseQueryAndFetchStudies(query) {
return new Promise((resolve, reject) => {
const url = query.url;
if (!url) {
reject(new Error("No URL was specified. Use ?url=$yourURL"));
}
// Define a request to the server to retrieve the study data
// as JSON, given a URL that was in the Route
const oReq = new XMLHttpRequest();
// Add event listeners for request failure
oReq.addEventListener("error", error => {
log.warn("An error occurred while retrieving the JSON data");
reject(error);
});
// When the JSON has been returned, parse it into a JavaScript Object
// and render the OHIF Viewer with this data
oReq.addEventListener("load", () => {
// Parse the response content
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseText
if (!oReq.responseText) {
log.warn("Response was undefined");
reject(new Error("Response was undefined"));
}
log.info(JSON.stringify(oReq.responseText, null, 2));
const data = JSON.parse(oReq.responseText);
if (data.servers && query.studyInstanceUids) {
const server = data.servers.dicomWeb[0];
server.type = "dicomWeb";
const studyInstanceUids = query.studyInstanceUids.split(";");
const seriesInstanceUids = [];
retrieveStudiesMetadata(
server,
studyInstanceUids,
seriesInstanceUids
).then(
studies => {
resolve(studies);
},
error => {
reject(error);
}
);
} else {
resolve(data.studies);
}
});
// Open the Request to the server for the JSON data
// In this case we have a server-side route called /api/
// which responds to GET requests with the study data
log.info(`Sending Request to: ${url}`);
oReq.open("GET", url);
oReq.setRequestHeader("Accept", "application/json");
// Fire the request to the server
oReq.send();
});
}
async componentDidMount() {
try {
let { search } = this.props.location;
// Remove ? prefix which is included for some reason
search = search.slice(1, search.length);
const query = qs.parse(search);
const studies = await StandaloneRouting.parseQueryAndFetchStudies(query);
studyMetadataManager.purge();
// Map studies to new format, update metadata manager?
const updatedStudies = studies.map(study => {
const studyMetadata = new OHIFStudyMetadata(
study,
study.studyInstanceUid
);
const sopClassHandlerModules =
extensionManager.modules["sopClassHandlerModule"];
study.displaySets =
study.displaySets ||
studyMetadata.createDisplaySets(sopClassHandlerModules);
studyMetadata.setDisplaySets(study.displaySets);
// Updates WADO-RS metaDataManager
updateMetaDataManager(study);
studyMetadataManager.add(studyMetadata);
return study;
});
this.setState({ studies: updatedStudies });
} catch (error) {
this.setState({ error });
}
}
render() {
if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>;
} else if (!this.state.studies) {
return <div>Loading...</div>;
}
return <Viewer studies={this.state.studies} />;
}
}
export default StandaloneRouting;
Loaded 100 of 919 files, more files were not shown because too many files have changed in this diff. Show more