diff --git a/extensions/measurement-tracking/.webpack/webpack.dev.js b/extensions/measurement-tracking/.webpack/webpack.dev.js new file mode 100644 index 000000000..1ae308448 --- /dev/null +++ b/extensions/measurement-tracking/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/extensions/measurement-tracking/.webpack/webpack.prod.js b/extensions/measurement-tracking/.webpack/webpack.prod.js new file mode 100644 index 000000000..f51038902 --- /dev/null +++ b/extensions/measurement-tracking/.webpack/webpack.prod.js @@ -0,0 +1,44 @@ +const webpack = require('webpack'); +const merge = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const pkg = require('./../package.json'); + +const ROOT_DIR = path.join(__dirname, './..'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); + + return merge(commonConfig, { + devtool: 'source-map', + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'OHIFExtDefault', + libraryTarget: 'umd', + libraryExport: 'default', + filename: pkg.main, + }, + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + ], + }); +}; diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json new file mode 100644 index 000000000..7af4e54f1 --- /dev/null +++ b/extensions/measurement-tracking/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ohif/extension-measurement-tracking", + "version": "0.0.1", + "description": "Tracking features and functionality for basic image viewing", + "author": "OHIF Core Team", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/index.umd.js", + "module": "src/index.js", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=10", + "npm": ">=6", + "yarn": ">=1.19.1" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:dicom-pdf": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev" + }, + "peerDependencies": { + "@ohif/core": "^0.50.0", + "prop-types": "^15.6.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "webpack": "^4.0.0", + "dcmjs": "^0.12.4" + }, + "dependencies": { + "@babel/runtime": "7.7.6" + } +} diff --git a/extensions/measurement-tracking/src/Panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/Panels/PanelMeasurementTableTracking/index.js new file mode 100644 index 000000000..b6e06f443 --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelMeasurementTableTracking/index.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { + MeasurementsPanel, + Button, + ButtonGroup, + Icon, + IconButton, +} from '@ohif/ui'; + +export default function MeasurementTable({ servicesManager, commandsManager }) { + const { MeasurementService } = servicesManager.services; + + console.log('MeasurementTable rendering!!!!!!!!!!!!!'); + + const actionButtons = ( + + alert('Export')}> + + + + + + + + ); + + const descriptionData = { + date: '07-Sep-2010', + modality: 'CT', + description: 'CHEST/ABD/PELVIS W CONTRAST', + }; + + const activeMeasurementItem = 0; + + const measurementTableData = { + title: 'Measurements', + amount: 10, + data: new Array(10).fill({}).map((el, i) => ({ + id: i + 1, + label: 'Label short description', + displayText: '24.0 x 24.0 mm (S:4, I:22)', + isActive: activeMeasurementItem === i + 1, + })), + // onClick: id => setActiveMeasurementItem(s => (s === id ? null : id)), + onClick: () => {}, + onEdit: id => alert(`Edit: ${id}`), + }; + + return ( + + ); +} diff --git a/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/PanelStudyBrowser.jsx b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/PanelStudyBrowser.jsx new file mode 100644 index 000000000..97564c115 --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/PanelStudyBrowser.jsx @@ -0,0 +1,284 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { StudyBrowser, useImageViewer } from '@ohif/ui'; + +/** + * + * @param {*} param0 + */ +function PanelStudyBrowser({ + DisplaySetService, + getImageSrc, + getStudiesForPatientByStudyInstanceUID, + requestDisplaySetCreationForStudy, + dataSource, +}) { + // Normally you nest the components so the tree isn't so deep, and the data + // doesn't have to have such an intense shape. This works well enough for now. + // Tabs --> Studies --> DisplaySets --> Thumbnails + const [{ StudyInstanceUIDs }, dispatch] = useImageViewer(); + const [activeTabName, setActiveTabName] = useState('primary'); + const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([]); + const [studyDisplayList, setStudyDisplayList] = useState([]); + const [displaySets, setDisplaySets] = useState([]); + const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); + + // ~~ studyDisplayList + useEffect(() => { + // Fetch all studies for the patient in each primary study + async function fetchStudiesForPatient(StudyInstanceUID) { + const qidoStudiesForPatient = + (await getStudiesForPatientByStudyInstanceUID(StudyInstanceUID)) || []; + + // TODO: This should be "naturalized DICOM JSON" studies + const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient); + const actuallyMappedStudies = mappedStudies.map(qidoStudy => { + return { + studyInstanceUid: qidoStudy.StudyInstanceUID, + date: qidoStudy.StudyDate, + description: qidoStudy.StudyDescription, + modalities: qidoStudy.ModalitiesInStudy, + numInstances: qidoStudy.NumInstances, + // displaySets: [] + }; + }); + + setStudyDisplayList(actuallyMappedStudies); + } + + StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid)); + }, [StudyInstanceUIDs, getStudiesForPatientByStudyInstanceUID]); + + // ~~ Initial Thumbnails + useEffect(() => { + const currentDisplaySets = DisplaySetService.activeDisplaySets; + currentDisplaySets.forEach(async dSet => { + const newImageSrcEntry = {}; + const displaySet = DisplaySetService.getDisplaySetByUID( + dSet.displaySetInstanceUID + ); + const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); + const imageId = imageIds[Math.floor(imageIds.length / 2)]; + + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId); + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); + }); + }, []); + + // ~~ displaySets + useEffect(() => { + // TODO: Are we sure `activeDisplaySets` will always be accurate? + const currentDisplaySets = DisplaySetService.activeDisplaySets; + const mappedDisplaySets = _mapDisplaySets( + currentDisplaySets, + thumbnailImageSrcMap + ); + + setDisplaySets(mappedDisplaySets); + }, [thumbnailImageSrcMap]); + + // ~~ subscriptions --> displaySets + useEffect(() => { + // DISPLAY_SETS_ADDED returns an array of DisplaySets that were added + const SubscriptionDisplaySetsAdded = DisplaySetService.subscribe( + DisplaySetService.EVENTS.DISPLAY_SETS_ADDED, + newDisplaySets => { + newDisplaySets.forEach(async dSet => { + const newImageSrcEntry = {}; + const displaySet = DisplaySetService.getDisplaySetByUID( + dSet.displaySetInstanceUID + ); + const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); + const imageId = imageIds[Math.floor(imageIds.length / 2)]; + + // When the image arrives, render it and store the result in the thumbnailImgSrcMap + newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc( + imageId + ); + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); + }); + } + ); + + // TODO: Will this always hold _all_ the displaySets we care about? + // DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets` + const SubscriptionDisplaySetsChanged = DisplaySetService.subscribe( + DisplaySetService.EVENTS.DISPLAY_SETS_CHANGED, + changedDisplaySets => { + const mappedDisplaySets = _mapDisplaySets( + changedDisplaySets, + thumbnailImageSrcMap + ); + + setDisplaySets(mappedDisplaySets); + } + ); + + return () => { + SubscriptionDisplaySetsAdded.unsubscribe(); + SubscriptionDisplaySetsChanged.unsubscribe(); + }; + }, []); + + const tabs = _createStudyBrowserTabs( + StudyInstanceUIDs, + studyDisplayList, + displaySets + ); + + // TODO: Should not fire this on "close" + function _handleStudyClick(StudyInstanceUID) { + const shouldCollapseStudy = expandedStudyInstanceUIDs.includes( + StudyInstanceUID + ); + const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy + // eslint-disable-next-line prettier/prettier + ? [...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)] + : [...expandedStudyInstanceUIDs, StudyInstanceUID]; + + setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); + + if (!shouldCollapseStudy) { + requestDisplaySetCreationForStudy(DisplaySetService, StudyInstanceUID); + } + } + + return ( + { + setActiveTabName(clickedTabName); + }} + /> + ); +} + +PanelStudyBrowser.propTypes = { + DisplaySetService: PropTypes.shape({ + EVENTS: PropTypes.object.isRequired, + activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired, + getDisplaySetByUID: PropTypes.func.isRequired, + hasDisplaySetsForStudy: PropTypes.func.isRequired, + subscribe: PropTypes.func.isRequired, + }).isRequired, + dataSource: PropTypes.shape({ + getImageIdsForDisplaySet: PropTypes.func.isRequired, + }).isRequired, + getImageSrc: PropTypes.func.isRequired, + getStudiesForPatientByStudyInstanceUID: PropTypes.func.isRequired, + requestDisplaySetCreationForStudy: PropTypes.func.isRequired, +}; + +export default PanelStudyBrowser; + +/** + * Maps from the DataSource's format to a naturalized object + * + * @param {*} studies + */ +function _mapDataSourceStudies(studies) { + return studies.map(study => { + // TODO: Why does the data source return in this format? + return { + AccessionNumber: study.accession, + StudyDate: study.date, + StudyDescription: study.description, + NumInstances: study.instances, + ModalitiesInStudy: study.modalities, + PatientID: study.mrn, + PatientName: study.patientName, + StudyInstanceUID: study.studyInstanceUid, + StudyTime: study.time, + }; + }); +} + +function _mapDisplaySets(displaySets, thumbnailImageSrcMap) { + return displaySets.map(ds => { + const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; + return { + displaySetInstanceUID: ds.displaySetInstanceUID, + description: ds.SeriesDescription, + seriesNumber: ds.SeriesNumber, + modality: ds.Modality, + date: ds.SeriesDate, + numInstances: ds.numImageFrames, + StudyInstanceUID: ds.StudyInstanceUID, + componentType: 'thumbnail', // 'thumbnailNoImage' || 'thumbnailTracked' // TODO: PUT THIS SOMEWHERE ELSE + imageSrc, + dragData: { + type: 'displayset', + displaySetInstanceUID: ds.displaySetInstanceUID, + // .. Any other data to pass + }, + }; + }); +} + + +/** + * + * @param {string[]} primaryStudyInstanceUIDs + * @param {object[]} studyDisplayList + * @param {string} studyDisplayList.studyInstanceUid + * @param {string} studyDisplayList.date + * @param {string} studyDisplayList.description + * @param {string} studyDisplayList.modalities + * @param {number} studyDisplayList.numInstances + * @param {object[]} displaySets + * @returns tabs - The prop object expected by the StudyBrowser component + */ +function _createStudyBrowserTabs( + primaryStudyInstanceUIDs, + studyDisplayList, + displaySets +) { + const primaryStudies = []; + const recentStudies = []; + const allStudies = []; + + studyDisplayList.forEach(study => { + const displaySetsForStudy = displaySets.filter( + ds => ds.StudyInstanceUID === study.studyInstanceUid + ); + const tabStudy = Object.assign({}, study, { + displaySets: displaySetsForStudy, + }); + + if (primaryStudyInstanceUIDs.includes(study.studyInstanceUid)) { + primaryStudies.push(tabStudy); + } else { + // TODO: Filter allStudies to dates within one year of current date + recentStudies.push(tabStudy); + allStudies.push(tabStudy); + } + }); + + const tabs = [ + { + name: 'primary', + label: 'Primary', + studies: primaryStudies, + }, + { + name: 'recent', + label: 'Recent', + studies: recentStudies, + }, + { + name: 'all', + label: 'All', + studies: allStudies, + }, + ]; + + return tabs; +} diff --git a/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js new file mode 100644 index 000000000..baf46c84a --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getImageSrcFromImageId.js @@ -0,0 +1,19 @@ +/** + * @param {*} cornerstone + * @param {*} imageId + */ +function getImageSrcFromImageId(cornerstone, imageId) { + return new Promise((resolve, reject) => { + cornerstone + .loadAndCacheImage(imageId) + .then(image => { + const canvas = document.createElement('canvas'); + cornerstone.renderToCanvas(canvas, image); + + resolve(canvas.toDataURL()); + }) + .catch(reject); + }); +} + +export default getImageSrcFromImageId; diff --git a/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js new file mode 100644 index 000000000..9327383ee --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js @@ -0,0 +1,23 @@ +async function getStudiesForPatientByStudyInstanceUID( + dataSource, + StudyInstanceUID +) { + // TODO: The `DicomMetadataStore` should short-circuit both of these requests + // Data _could_ be here from route query, or if using JSON data source + // We could also force this to "await" these values being available in the DICOMStore? + // Kind of like promise fulfillment in cornerstone-wado-image-loader when there are multiple + // outgoing requests for the same data + const getStudyResult = await dataSource.query.studies.search({ + studyInstanceUid: StudyInstanceUID, + }); + + // TODO: To Erik's point, the data source likely shouldn't deviate from + // Naturalized DICOM JSON when returning. It makes things like this awkward (mrn) + if (getStudyResult && getStudyResult.length && getStudyResult[0].mrn) { + return dataSource.query.studies.search({ + patientId: getStudyResult[0].mrn, + }); + } +} + +export default getStudiesForPatientByStudyInstanceUID; diff --git a/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/index.jsx b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/index.jsx new file mode 100644 index 000000000..c3bb27d01 --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/index.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +// +import PanelStudyBrowser from './PanelStudyBrowser'; +import getImageSrcFromImageId from './getImageSrcFromImageId'; +import getStudiesForPatientByStudyInstanceUID from './getStudiesForPatientByStudyInstanceUID'; +import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy'; + +/** + * Wraps the PanelStudyBrowser and provides features afforded by managers/services + * + * @param {object} params + * @param {object} commandsManager + * @param {object} extensionManager + */ +function WrappedPanelStudyBrowser({ + commandsManager, + extensionManager, + servicesManager, +}) { + // TODO: This should be made available a different way; route should have + // already determined our datasource + const dataSource = extensionManager.getDataSources('dicomweb')[0]; + const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( + null, + dataSource + ); + const _getImageSrcFromImageId = _createGetImageSrcFromImageIdFn( + commandsManager.getCommand.bind(commandsManager) + ); + const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind( + null, + dataSource + ); + + return ( + + ); +} + +/** + * Grabs cornerstone library reference using a dependent command from + * the @ohif/extension-cornerstone extension. Then creates a helper function + * that can take an imageId and return an image src. + * + * @param {func} getCommand - CommandManager's getCommand method + * @returns {func} getImageSrcFromImageId - A utility function powered by + * cornerstone + */ +function _createGetImageSrcFromImageIdFn(getCommand) { + try { + const command = getCommand('getCornerstoneLibraries', 'VIEWER'); + if (!command) { + return; + } + const { cornerstone } = command.commandFn(); + + return getImageSrcFromImageId.bind(null, cornerstone); + } catch (ex) { + throw new Error('Required command not found'); + } +} + +WrappedPanelStudyBrowser.propTypes = { + commandsManager: PropTypes.object.isRequired, + extensionManager: PropTypes.object.isRequired, + servicesManager: PropTypes.object.isRequired, +}; + +export default WrappedPanelStudyBrowser; diff --git a/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js new file mode 100644 index 000000000..2cf412f3e --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/PanelStudyBrowserTracking/requestDisplaySetCreationForStudy.js @@ -0,0 +1,14 @@ +function requestDisplaySetCreationForStudy( + dataSource, + DisplaySetService, + StudyInstanceUID +) { + // TODO: is this already short-circuited by the map of Retrieve promises? + if (DisplaySetService.hasDisplaySetsForStudy(StudyInstanceUID)) { + return; + } + + dataSource.retrieveSeriesMetadata({ StudyInstanceUID }); +} + +export default requestDisplaySetCreationForStudy; diff --git a/extensions/measurement-tracking/src/Panels/index.js b/extensions/measurement-tracking/src/Panels/index.js new file mode 100644 index 000000000..4db70ae02 --- /dev/null +++ b/extensions/measurement-tracking/src/Panels/index.js @@ -0,0 +1,4 @@ +import PanelStudyBrowserTracking from './PanelStudyBrowserTracking'; +import PanelMeasurementTableTracking from './PanelMeasurementTableTracking'; + +export { PanelMeasurementTableTracking, PanelStudyBrowserTracking }; diff --git a/extensions/measurement-tracking/src/Viewports/OHIFCornerstoneViewport.js b/extensions/measurement-tracking/src/Viewports/OHIFCornerstoneViewport.js new file mode 100644 index 000000000..049d93c15 --- /dev/null +++ b/extensions/measurement-tracking/src/Viewports/OHIFCornerstoneViewport.js @@ -0,0 +1,193 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import cornerstone from 'cornerstone-core'; +import CornerstoneViewport from 'react-cornerstone-viewport'; +import OHIF from '@ohif/core'; +import debounce from 'lodash.debounce'; +import throttle from 'lodash.throttle'; + + +// const cine = viewportSpecificData.cine; + +// isPlaying = cine.isPlaying === true; +// frameRate = cine.cineFrameRate || frameRate; + +const { StackManager } = OHIF.utils; + +class OHIFCornerstoneViewport extends Component { + state = { + viewportData: null, + }; + + static defaultProps = { + customProps: {}, + }; + + static propTypes = { + displaySet: PropTypes.object, + viewportIndex: PropTypes.number, + dataSource: PropTypes.object, + children: PropTypes.node, + customProps: PropTypes.object, + }; + + static name = 'OHIFCornerstoneViewport'; + + static init() { + console.log('OHIFCornerstoneViewport init()'); + } + + // TODO: Still needed? Better way than import `OHIF` and destructure? + // Why is this managed by `core`? + static destroy() { + console.log('OHIFCornerstoneViewport destroy()'); + StackManager.clearStacks(); + } + + /** + * Obtain the CornerstoneTools Stack for the specified display set. + * + * @param {Object} displaySet + * @param {Object} dataSource + * @return {Object} CornerstoneTools Stack + */ + static getCornerstoneStack(displaySet, dataSource) { + const { frameIndex } = displaySet; + + // Get stack from Stack Manager + const storedStack = StackManager.findOrCreateStack(displaySet, dataSource); + + // Clone the stack here so we don't mutate it + const stack = Object.assign({}, storedStack); + + stack.currentImageIdIndex = frameIndex; + + return stack; + } + + getViewportData = async displaySet => { + let viewportData; + + const { dataSource } = this.props; + + const stack = OHIFCornerstoneViewport.getCornerstoneStack( + displaySet, + dataSource + ); + + viewportData = { + StudyInstanceUID: displaySet.StudyInstanceUID, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + stack, + }; + + return viewportData; + }; + + setStateFromProps() { + const { displaySet } = this.props; + const { + StudyInstanceUID, + displaySetInstanceUID, + sopClassUids, + } = displaySet; + + if (!StudyInstanceUID || !displaySetInstanceUID) { + return; + } + + if (sopClassUids && sopClassUids.length > 1) { + console.warn( + 'More than one SOPClassUID in the same series is not yet supported.' + ); + } + + this.getViewportData(displaySet).then(viewportData => { + this.setState({ + viewportData, + }); + }); + } + + componentDidMount() { + this.setStateFromProps(); + } + + componentDidUpdate(prevProps) { + const { displaySet } = this.props; + const prevDisplaySet = prevProps.displaySet; + + if ( + displaySet.displaySetInstanceUID !== + prevDisplaySet.displaySetInstanceUID || + displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID || + displaySet.frameIndex !== prevDisplaySet.frameIndex + ) { + this.setStateFromProps(); + } + } + + render() { + let childrenWithProps = null; + + if (!this.state.viewportData) { + return null; + } + const { viewportIndex } = this.props; + const { + imageIds, + currentImageIdIndex, + // If this comes from the instance, would be a better default + // `FrameTime` in the instance + // frameRate = 0, + } = this.state.viewportData.stack; + + // TODO: Does it make more sense to use Context? + if (this.props.children && this.props.children.length) { + childrenWithProps = this.props.children.map((child, index) => { + return ( + child && + React.cloneElement(child, { + viewportIndex: this.props.viewportIndex, + key: index, + }) + ); + }); + } + + const debouncedNewImageHandler = debounce( + ({ currentImageIdIndex, sopInstanceUid }) => { + const { displaySet } = this.props; + const { StudyInstanceUID } = displaySet; + if (currentImageIdIndex > 0) { + this.props.onNewImage({ + StudyInstanceUID, + SOPInstanceUID: sopInstanceUid, + frameIndex: currentImageIdIndex, + activeViewportIndex: viewportIndex, + }); + } + }, + 700 + ); + + return ( + <> + + {childrenWithProps} + + ); + } +} + +export default OHIFCornerstoneViewport; diff --git a/extensions/measurement-tracking/src/getCommandsModule.js b/extensions/measurement-tracking/src/getCommandsModule.js new file mode 100644 index 000000000..5a500b030 --- /dev/null +++ b/extensions/measurement-tracking/src/getCommandsModule.js @@ -0,0 +1,64 @@ +// SEE: +// https://github.com/OHIF/Viewers/blob/b58aa4575ab72fe3f493cc5a4261b4f8256516ab/platform/viewer/src/appExtensions/MeasurementsPanel/index.js#L18-L49 +import React from 'react'; +import { useViewportGrid } from '@ohif/ui'; + +function getCommandsModule({ servicesManager }) { + const { UIDialogService } = servicesManager.services; + + const definitions = { + toggleLayoutSelectionDialog: { + commandFn: () => { + if (!UIDialogService) { + window.alert('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + // TODO: use SimpleDialog component + // TODO: update position on window resize + // TODO: Expand service API to check if dialog w/ ID is already open + // TODO: Import and call `useViewportGrid` + UIDialogService.dismiss({ id: 'layoutSelection' }); + UIDialogService.create({ + id: 'layoutSelection', + centralize: true, + isDraggable: false, + showOverlay: true, + content: Test, + }); + + }, + storeContexts: [], + options: {}, + context: 'VIEWER', + }, + } + + return { + definitions, + defaultContext: 'VIEWER', + }; +} + +function Test() { + const [ + { numCols, numRows, activeViewportIndex, viewports }, + dispatch, + ] = useViewportGrid(); + + return
{ + dispatch({ type: '', payload: { + numCols: 3, + numRows: 3, + activeViewportIndex: 0, + viewports: [], + }}) + }} + style={{ color: 'white' }} + > + Hello World! +
+} + +export default getCommandsModule; diff --git a/extensions/measurement-tracking/src/getContextModule.js b/extensions/measurement-tracking/src/getContextModule.js new file mode 100644 index 000000000..ddca1fcfd --- /dev/null +++ b/extensions/measurement-tracking/src/getContextModule.js @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { ViewModelContext } from '@ohif/core'; + +const HelloWorldContext = React.createContext({ + message: 'HelloWorldContextTesting', + setMessage: () => {}, +}); + +HelloWorldContext.displayName = 'HelloWorldContext'; + +function HelloWorldContextProvider({ children }) { + const [message, setMessage] = useState('HelloWorldContextTesting'); + + return ( + + {children} + + ); +} + +function getContextModule() { + return [ + { + name: 'HelloWorldContext', + context: HelloWorldContext, + provider: HelloWorldContextProvider, + }, + ]; +} + +export { HelloWorldContext }; + +export default getContextModule; diff --git a/extensions/measurement-tracking/src/getPanelModule.js b/extensions/measurement-tracking/src/getPanelModule.js new file mode 100644 index 000000000..b1ef8911d --- /dev/null +++ b/extensions/measurement-tracking/src/getPanelModule.js @@ -0,0 +1,41 @@ +import { + PanelStudyBrowserTracking, + PanelMeasurementTableTracking, +} from './Panels'; + +// TODO: +// - No loading UI exists yet +// - cancel promises when component is destroyed +// - show errors in UI for thumbnails if promise fails +function getPanelModule({ + commandsManager, + extensionManager, + servicesManager, +}) { + return [ + { + name: 'seriesList', + iconName: 'group-layers', + iconLabel: 'Studies', + label: 'Studies', + component: PanelStudyBrowserTracking.bind(null, { + commandsManager, + extensionManager, + servicesManager, + }), + }, + { + name: 'measure', + iconName: 'list-bullets', + iconLabel: 'Measure', + label: 'Measurements', + component: PanelMeasurementTableTracking.bind(null, { + commandsManager, + extensionManager, + servicesManager, + }), + }, + ]; +} + +export default getPanelModule; diff --git a/extensions/measurement-tracking/src/getViewportModule.js b/extensions/measurement-tracking/src/getViewportModule.js new file mode 100644 index 000000000..d9ac86081 --- /dev/null +++ b/extensions/measurement-tracking/src/getViewportModule.js @@ -0,0 +1,31 @@ +import React from 'react'; + +const Component = React.lazy(() => { + return import('./Viewports/OHIFCornerstoneViewport'); +}); + +const OHIFCornerstoneViewport = props => { + return ( + Loading...}> + + + ); +}; + +function getViewportModule({ commandsManager }) { + const ExtendedOHIFCornerstoneViewport = props => { + const onNewImageHandler = jumpData => { + alert('here we are!') + commandsManager.runCommand('jumpToImage', jumpData); + }; + return ( + + ); + }; + + return [ + { name: 'cornerstone-tracked', component: ExtendedOHIFCornerstoneViewport }, + ]; +}; + +export default getViewportModule; diff --git a/extensions/measurement-tracking/src/index.js b/extensions/measurement-tracking/src/index.js new file mode 100644 index 000000000..7aa5cc79b --- /dev/null +++ b/extensions/measurement-tracking/src/index.js @@ -0,0 +1,15 @@ +import getCommandsModule from './getCommandsModule.js'; +import getContextModule from './getContextModule.js'; +import getPanelModule from './getPanelModule.js'; +import getViewportModule from './getViewportModule.js'; + +export default { + /** + * Only required property. Should be a unique value across all extensions. + */ + id: 'org.ohif.measurement-tracking', + getCommandsModule, + getContextModule, + getPanelModule, + getViewportModule, +}; diff --git a/modes/longitudinal/.webpack/webpack.dev.js b/modes/longitudinal/.webpack/webpack.dev.js new file mode 100644 index 000000000..1ae308448 --- /dev/null +++ b/modes/longitudinal/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/modes/longitudinal/.webpack/webpack.prod.js b/modes/longitudinal/.webpack/webpack.prod.js new file mode 100644 index 000000000..ca612cd37 --- /dev/null +++ b/modes/longitudinal/.webpack/webpack.prod.js @@ -0,0 +1,44 @@ +const webpack = require('webpack'); +const merge = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const pkg = require('./../package.json'); + +const ROOT_DIR = path.join(__dirname, './..'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); + + return merge(commonConfig, { + devtool: 'source-map', + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'OHIFExtCornerstone', + libraryTarget: 'umd', + libraryExport: 'default', + filename: pkg.main, + }, + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + ], + }); +}; diff --git a/modes/longitudinal/LICENSE b/modes/longitudinal/LICENSE new file mode 100644 index 000000000..19e20dd35 --- /dev/null +++ b/modes/longitudinal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modes/longitudinal/babel.config.js b/modes/longitudinal/babel.config.js new file mode 100644 index 000000000..325ca2a8e --- /dev/null +++ b/modes/longitudinal/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/modes/longitudinal/package.json b/modes/longitudinal/package.json new file mode 100644 index 000000000..03ed71133 --- /dev/null +++ b/modes/longitudinal/package.json @@ -0,0 +1,35 @@ +{ + "name": "@ohif/mode-longitudinal", + "version": "0.0.1", + "description": "Longitudinal Workflow", + "author": "OHIF", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/index.umd.js", + "module": "src/index.js", + "engines": { + "node": ">=10", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:cornerstone": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage" + }, + "peerDependencies": {}, + "dependencies": { + "@babel/runtime": "7.7.6" + } +} diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js new file mode 100644 index 000000000..a929112bf --- /dev/null +++ b/modes/longitudinal/src/index.js @@ -0,0 +1,98 @@ +export default function mode({ modeConfiguration }) { + return { + // TODO: We're using this as a route segment + // We should not be. + id: 'longitudinal-workflow', + validationTags: { + study: [], + series: [], + }, + isValidMode: (studyTags, seriesTags) => { + // All series are welcome in this mode! + return true; + }, + routes: [ + { + path: 'longitudinal', + init: ({ servicesManager, extensionManager }) => { + const { ToolBarService } = servicesManager.services; + ToolBarService.init(extensionManager); + ToolBarService.addButtons([ + { + id: 'Zoom', + namespace: 'org.ohif.cornerstone.toolbarModule.Zoom', + }, + { + id: 'Levels', + namespace: 'org.ohif.cornerstone.toolbarModule.Wwwc', + }, + { + id: 'Pan', + namespace: 'org.ohif.cornerstone.toolbarModule.Pan', + }, + { + id: 'Capture', + namespace: 'org.ohif.cornerstone.toolbarModule.Capture', + }, + { + id: 'Layout', + namespace: 'org.ohif.default.toolbarModule.Layout', + }, + { + id: 'Annotate', + namespace: 'org.ohif.cornerstone.toolbarModule.Annotate', + }, + { + id: 'Bidirectional', + namespace: 'org.ohif.cornerstone.toolbarModule.Bidirectional', + }, + { + id: 'Ellipse', + namespace: 'org.ohif.cornerstone.toolbarModule.Ellipse', + }, + { + id: 'Length', + namespace: 'org.ohif.cornerstone.toolbarModule.Length', + }, + ]); + + // Could import layout selector here from org.ohif.default (when it exists!) + ToolBarService.setToolBarLayout([ + // Primary + { + tools: ['Zoom', 'Levels', 'Pan', 'Capture', 'Layout'], + moreTools: ['Zoom'], + }, + // Secondary + { + tools: ['Annotate', 'Bidirectional', 'Ellipse', 'Length'], + }, + ]); + }, + layoutTemplate: ({ routeProps }) => { + return { + id: 'org.ohif.default.layoutTemplateModule.viewerLayout', + props: { + // named slots + leftPanels: ['org.ohif.default.panelModule.seriesList'], + // TODO: Should be optional, or required to pass empty array for slots? + rightPanels: [], // // ['org.ohif.default.panelModule.measure'], + viewports: [ + { + namespace: 'org.ohif.measurement-tracking.viewportModule.cornerstone-tracked', + displaySetsToDisplay: [ + 'org.ohif.default.sopClassHandlerModule.stack', + ], + }, + ], + }, + }; + }, + }, + ], + extensions: ['org.ohif.default', 'org.ohif.cornerstone', 'org.ohif.measurement-tracking'], + sopClassHandlers: ['org.ohif.default.sopClassHandlerModule.stack'], + }; +} + +window.longitudinalMode = mode({}); diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 6d4b0a545..4c22092c5 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -58,8 +58,10 @@ "@ohif/extension-dicom-microscopy": "^0.50.6", "@ohif/extension-dicom-pdf": "^1.0.1", "@ohif/extension-lesion-tracker": "^0.2.0", + "@ohif/extension-measurement-tracking": "^0.0.1", "@ohif/extension-vtk": "^1.5.6", "@ohif/i18n": "^0.52.8", + "@ohif/mode-longitudinal": "^0.0.1", "@ohif/ui": "^1.4.4", "@tanem/react-nprogress": "^1.1.25", "@types/react": "^16.0.0", diff --git a/platform/viewer/src/App.jsx b/platform/viewer/src/App.jsx index 9eda2e092..22e3c23c0 100644 --- a/platform/viewer/src/App.jsx +++ b/platform/viewer/src/App.jsx @@ -19,6 +19,7 @@ import appInit from './appInit.js'; // TODO: Temporarily for testing import '@ohif/mode-example'; +import '@ohif/mode-longitudinal'; /** * ENV Variable to determine routing behavior diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index 62fcf2c51..4d236ee73 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -66,6 +66,7 @@ function appInit(appConfigOrFunc, defaultExtensions) { // TODO: Remove this if (!appConfig.modes.length) { appConfig.modes.push(window.exampleMode); + appConfig.modes.push(window.longitudinalMode); } return { diff --git a/platform/viewer/src/components/ViewportGrid.jsx b/platform/viewer/src/components/ViewportGrid.jsx index 7ceeeddd5..5fb08add1 100644 --- a/platform/viewer/src/components/ViewportGrid.jsx +++ b/platform/viewer/src/components/ViewportGrid.jsx @@ -114,7 +114,7 @@ function ViewerViewportGrid(props) { }; // const ViewportPanes = React.useMemo(getViewportPanes, [ - // viewportComp'onents, + // viewportComponents, // activeViewportIndex, // viewportGrid, // ]); diff --git a/platform/viewer/src/index.js b/platform/viewer/src/index.js index b3ad3934c..c742a5c95 100644 --- a/platform/viewer/src/index.js +++ b/platform/viewer/src/index.js @@ -23,11 +23,16 @@ import ReactDOM from 'react-dom'; */ import OHIFDefaultExtension from '@ohif/extension-default'; import OHIFCornerstoneExtension from '@ohif/extension-cornerstone'; +import OHIFMeasurementTrackingExtension from '@ohif/extension-measurement-tracking'; /** Combine our appConfiguration and "baked-in" extensions */ const appProps = { config: window ? window.config : {}, - defaultExtensions: [OHIFDefaultExtension, OHIFCornerstoneExtension], + defaultExtensions: [ + OHIFDefaultExtension, + OHIFCornerstoneExtension, + OHIFMeasurementTrackingExtension, + ], }; /** Create App */ diff --git a/yarn.lock b/yarn.lock index bcd4cdce0..df2fa9e3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,27 +868,13 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime@7.1.2": - version "7.1.2" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.2.tgz#81c89935f4647706fc54541145e6b4ecfef4b8e3" - integrity sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg== - dependencies: - regenerator-runtime "^0.12.0" - -"@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6": +"@babel/runtime@7.1.2", "@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4": version "7.7.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.6.tgz#d18c511121aff1b4f2cd1d452f1bac9601dd830f" integrity sha512-BWAJxpNVa0QlE5gZdWjSxXtemZyZ9RmrmVozxt3NUXeZhVIJ5ANyqmMc0JDrivBZyxUuQvFxlvH4OWWOogGfUw== dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4": - version "7.9.6" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f" - integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ== - dependencies: - regenerator-runtime "^0.13.4" - "@babel/standalone@^7.4.5": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.8.6.tgz#1364534775c83bf7b7988e4ca98823bef56a0a53" @@ -17296,11 +17282,6 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.12.0: - version "0.12.1" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" - integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== - regenerator-runtime@^0.13.1, regenerator-runtime@^0.13.2: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"