Merge in feat/ui-v2-rebase-services

This commit is contained in:
James A. Petts committed 2020-06-11 18:26:38 +02:00
commit c5e6e97980
66 files changed
+3390 -3355

No files matched your search

+89 -59
View File
@@ -195,69 +195,99 @@ const _connectToolsToMeasurementService = measurementService => {
const csToolsVer4MeasurementSource = _initMeasurementService(
measurementService
);
const { addOrUpdate } = csToolsVer4MeasurementSource;
const { addOrUpdate, remove } = csToolsVer4MeasurementSource;
const elementEnabledEvt = cornerstone.EVENTS.ELEMENT_ENABLED;
/* Measurement Service Events */
cornerstone.events.addEventListener(
cornerstone.EVENTS.ELEMENT_ENABLED,
event => {
// const {
// MEASUREMENT_ADDED,
// MEASUREMENT_UPDATED,
// } = measurementService.EVENTS;
cornerstone.events.addEventListener(elementEnabledEvt, evt => {
// TODO: Debounced update of measurements that are modified
// measurementService.subscribe(
// MEASUREMENT_ADDED,
// ({ source, measurement }) => {
// if (![sourceId].includes(source.id)) {
// const annotation = getAnnotation('Length', measurement.id);
function addMeasurement(csToolsEvent) {
console.log('CSTOOLS::addOrUpdate', csToolsEvent, csToolsEvent.detail);
try {
const evtDetail = csToolsEvent.detail;
const { toolName, toolType, measurementData } = evtDetail;
const csToolName = toolName || measurementData.toolType || toolType;
const measurementId = addOrUpdate(csToolName, evtDetail);
// console.log(
// 'Measurement Service [Cornerstone]: Measurement added',
// measurement
// );
// console.log('Mapped annotation:', annotation);
// }
// }
// );
// measurementService.subscribe(
// MEASUREMENT_UPDATED,
// ({ source, measurement }) => {
// if (![sourceId].includes(source.id)) {
// const annotation = getAnnotation('Length', measurement.id);
// console.log(
// 'Measurement Service [Cornerstone]: Measurement updated',
// measurement
// );
// console.log('Mapped annotation:', annotation);
// }
// }
// );
const addOrUpdateMeasurement = csToolsAnnotation => {
try {
const { toolName, toolType, measurementData } = csToolsAnnotation;
const csToolName = toolName || measurementData.toolType || toolType;
csToolsAnnotation.id = measurementData._measurementServiceId;
addOrUpdate(csToolName, csToolsAnnotation);
} catch (error) {
console.warn('Failed to add or update measurement:', error);
if (measurementId) {
measurementData.id = measurementId;
}
};
[csTools.EVENTS.MEASUREMENT_ADDED].forEach(csToolsEvtName => {
event.detail.element.addEventListener(
csToolsEvtName,
({ detail: csToolsAnnotation }) => {
console.log(`csTools::${csToolsEvtName}`);
addOrUpdateMeasurement(csToolsAnnotation);
}
);
});
} catch (error) {
console.warn('Failed to add measurement:', error);
}
}
);
function updateMeasurement(csToolsEvent) {
try {
if (!csToolsEvent.detail.measurementData.id) {
return;
}
const evtDetail = csToolsEvent.detail;
const { toolName, toolType, measurementData } = evtDetail;
const csToolName = toolName || measurementData.toolType || toolType;
evtDetail.id = csToolsEvent.detail.measurementData.id;
addOrUpdate(csToolName, evtDetail);
//
} catch (error) {
console.warn('Failed to update measurement:', error);
}
}
function removeMeasurement(csToolsEvent) {
console.log('~~ removeEvt', csToolsEvent);
try {
if (csToolsEvent.detail.measurementData.id) {
remove(csToolsEvent.detail.measurementData.id);
}
} catch (error) {
console.warn('Failed to remove measurement:', error);
}
}
const enabledElement = evt.detail.element;
const completedEvt = csTools.EVENTS.MEASUREMENT_COMPLETED;
const updatedEvt = csTools.EVENTS.MEASUREMENT_MODIFIED;
const removedEvt = csTools.EVENTS.MEASUREMENT_REMOVED;
enabledElement.addEventListener(completedEvt, addMeasurement);
enabledElement.addEventListener(updatedEvt, updateMeasurement);
enabledElement.addEventListener(removedEvt, removeMeasurement);
});
};
// const {
// MEASUREMENT_ADDED,
// MEASUREMENT_UPDATED,
// } = measurementService.EVENTS;
// measurementService.subscribe(
// MEASUREMENT_ADDED,
// ({ source, measurement }) => {
// if (![sourceId].includes(source.id)) {
// const annotation = getAnnotation('Length', measurement.id);
// console.log(
// 'Measurement Service [Cornerstone]: Measurement added',
// measurement
// );
// console.log('Mapped annotation:', annotation);
// }
// }
// );
// measurementService.subscribe(
// MEASUREMENT_UPDATED,
// ({ source, measurement }) => {
// if (![sourceId].includes(source.id)) {
// const annotation = getAnnotation('Length', measurement.id);
// console.log(
// 'Measurement Service [Cornerstone]: Measurement updated',
// measurement
// );
// console.log('Mapped annotation:', annotation);
// }
// }
// );
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ButtonGroup, Icon, IconButton } from '@ohif/ui';
function ActionButtons() {
return (
<React.Fragment>
<ButtonGroup onClick={() => alert('Export')}>
<Button
className="px-2 py-2 text-base text-white bg-black border-primary-main"
size="initial"
color="inherit"
>
Export
</Button>
<IconButton
className="px-2 text-white bg-black border-primary-main"
color="inherit"
size="initial"
>
<Icon name="arrow-down" />
</IconButton>
</ButtonGroup>
<Button
className="px-2 py-2 ml-2 text-base text-white bg-black border border-primary-main"
variant="outlined"
size="initial"
color="inherit"
onClick={() => alert('Create Report')}
>
Create Report
</Button>
</React.Fragment>
);
}
export default ActionButtons;
@@ -1,73 +0,0 @@
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 = (
<React.Fragment>
<ButtonGroup onClick={() => alert('Export')}>
<Button
className="text-white border-primary-main bg-black text-base py-2 px-2"
size="initial"
color="inherit"
>
Export
</Button>
<IconButton
className="bg-black border-primary-main px-2 text-white px-2"
color="inherit"
size="initial"
>
<Icon name="arrow-down" />
</IconButton>
</ButtonGroup>
<Button
className="text-white border border-primary-main bg-black text-base py-2 px-2 ml-2"
variant="outlined"
size="initial"
color="inherit"
onClick={() => alert('Create Report')}
>
Create Report
</Button>
</React.Fragment>
);
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)),
onEdit: id => alert(`Edit: ${id}`),
};
return (
<MeasurementsPanel
descriptionData={descriptionData}
measurementTableData={measurementTableData}
actionButtons={actionButtons}
/>
);
}
@@ -0,0 +1,55 @@
import React from 'react';
import { StudySummary, MeasurementTable } from '@ohif/ui';
import ActionButtons from './ActionButtons.jsx';
export default function PanelMeasurementTable({
servicesManager,
commandsManager,
}) {
const { MeasurementService } = servicesManager.services;
console.log('MeasurementTable rendering!!!!!!!!!!!!!');
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)),
onEdit: id => alert(`Edit: ${id}`),
};
return (
<>
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
<StudySummary
date={descriptionData.date}
modality={descriptionData.modality}
description={descriptionData.description}
/>
<MeasurementTable
title="Measurements"
amount={measurementTableData.data.length}
data={measurementTableData.data}
onClick={() => {}}
onEdit={id => alert(`Edit: ${id}`)}
/>
</div>
<div className="flex justify-center p-4">
<ActionButtons />
</div>
</>
);
}
+21 -18
View File
@@ -3,15 +3,16 @@
import React from 'react';
import { useViewportGrid } from '@ohif/ui';
function getCommandsModule ({ servicesManager }) {
function getCommandsModule({ servicesManager }) {
const { UIDialogService } = servicesManager.services;
const definitions = {
toggleLayoutSelectionDialog: {
commandFn: () => {
if (!UIDialogService) {
window.alert('Unable to show dialog; no UI Dialog Service available.');
window.alert(
'Unable to show dialog; no UI Dialog Service available.'
);
return;
}
@@ -27,13 +28,12 @@ function getCommandsModule ({ servicesManager }) {
showOverlay: true,
content: Test,
});
},
storeContexts: [],
options: {},
context: 'VIEWER',
},
}
};
return {
definitions,
@@ -47,19 +47,22 @@ function Test() {
dispatch,
] = useViewportGrid();
return <div
onClick={() => {
dispatch({ type: '', payload: {
numCols: 2,
numRows: 2,
activeViewportIndex: 0,
viewports: [],
}})
}}
style={{ color: 'white' }}
>
Hello World!
</div>
return (
<div
onClick={() => {
dispatch({
type: 'SET_LAYOUT',
payload: {
numCols: 2,
numRows: 2,
},
});
}}
style={{ color: 'white' }}
>
Hello World!
</div>
);
}
export default getCommandsModule;
+2 -2
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { WrappedPanelStudyBrowser } from './Panels';
import MeasurementTable from './MeasurementTable.js';
import PanelMeasurementTable from './PanelMeasurementTable.js';
// TODO:
// - No loading UI exists yet
@@ -15,7 +15,7 @@ function getPanelModule({
}) {
const wrappedMeasurementPanel = () => {
return (
<MeasurementTable
<PanelMeasurementTable
commandsManager={commandsManager}
servicesManager={servicesManager}
/>
@@ -1,8 +0,0 @@
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 });
};
@@ -1,38 +0,0 @@
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: 'OHIFExtDicomP10Downloader',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
});
};
@@ -1,28 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.1.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-dicom-p10-downloader@0.0.2...@ohif/extension-dicom-p10-downloader@0.1.0) (2020-04-23)
### Features
* configuration to hook into XHR Error handling ([e96205d](https://github.com/OHIF/Viewers/commit/e96205de35e5bec14dc8a9a8509db3dd4e6ecdb6))
## 0.0.2 (2020-04-15)
**Note:** Version bump only for package @ohif/extension-dicom-p10-downloader
# Change Log
All notable changes to this project will be documented in this file. See
[Conventional Commits](https://conventionalcommits.org) for commit guidelines.
-21
View File
@@ -1,21 +0,0 @@
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.
@@ -1 +0,0 @@
# @ohif/extension-dicom-p10-downloader
@@ -1,40 +0,0 @@
{
"name": "@ohif/extension-dicom-p10-downloader",
"version": "0.1.0",
"description": "OHIF extension for downloading DICOM P10 files",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=10",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"prepublishOnly": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "^2.6.0",
"dicom-parser": "^1.8.3",
"dicomweb-client": "^0.5.2"
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"dicomweb-client": "^0.6.0",
"file-saver": "^2.0.2",
"jszip": "^3.2.2"
}
}
@@ -1,103 +0,0 @@
import OHIF from '@ohif/core';
import {
save,
getDicomWebClientFromContext,
getSOPInstanceReferenceFromActiveViewport,
getSOPInstanceReferencesFromViewports,
} from './utils';
import _downloadAndZip from './downloadAndZip';
const {
utils: { Queue },
} = OHIF;
export function getCommands(context) {
const queue = new Queue(1);
const actions = {
/**
* @example Running this command using Commands Manager
* commandsManager.runCommand(
* 'downloadAndZip',
* {
* listOfUIDs: [...],
* options: {
* progress(status) {
* console.info('Progress:', (status.progress * 100).toFixed(2) + '%');
* }
* }
* },
* 'VIEWER'
* );
*/
downloadAndZip({ servers, dicomWebClient, listOfUIDs, options }) {
return save(
_downloadAndZip(
dicomWebClient || getDicomWebClientFromContext(context, servers),
listOfUIDs,
options
),
listOfUIDs
);
},
downloadAndZipSeriesOnViewports({ servers, viewports, progress }) {
const dicomWebClient = getDicomWebClientFromContext(context, servers);
const listOfUIDs = getSOPInstanceReferencesFromViewports(viewports);
return save(
_downloadAndZip(dicomWebClient, listOfUIDs, { progress }),
listOfUIDs
);
},
downloadAndZipSeriesOnActiveViewport({ servers, viewports, progress }) {
const dicomWebClient = getDicomWebClientFromContext(context, servers);
const listOfUIDs = getSOPInstanceReferenceFromActiveViewport(viewports);
return save(
_downloadAndZip(dicomWebClient, listOfUIDs, { progress }),
listOfUIDs
);
},
};
const definitions = {
downloadAndZip: {
commandFn: queue.bindSafe(actions.downloadAndZip, error),
storeContexts: ['servers'],
},
downloadAndZipSeriesOnViewports: {
commandFn: queue.bindSafe(actions.downloadAndZipSeriesOnViewports, error),
storeContexts: ['servers', 'viewports'],
options: { progress },
},
downloadAndZipSeriesOnActiveViewport: {
commandFn: queue.bindSafe(
actions.downloadAndZipSeriesOnActiveViewport,
error
),
storeContexts: ['servers', 'viewports'],
options: { progress },
},
};
return {
actions,
definitions,
};
}
/**
* Utils
*/
function progress(status) {
OHIF.log.info(
'Download and Zip Progress:',
(status.progress * 100.0).toFixed(2) + '%'
);
}
function error(e) {
if (e.message === 'Queue limit reached') {
OHIF.log.warn('A download is already in progress, please wait.');
} else {
OHIF.log.error(e);
}
}
@@ -1,244 +0,0 @@
import OHIF from '@ohif/core';
import { api } from 'dicomweb-client';
import dicomParser from 'dicom-parser';
import JSZip from 'jszip';
/**
* Constants
*/
const {
utils: {
isDicomUid,
hierarchicalListUtils,
progressTrackingUtils: progressUtils,
},
} = OHIF;
/**
* Public Methods
*/
/**
* Download and Zip all DICOM P10 instances from specified DICOM Web Client
* based on an hierarchical list of UIDs;
*
* @param {DICOMwebClient} dicomWebClient A DICOMwebClient instance through
* which the referenced instances will be retrieved;
* @param {Array} listOfUIDs The hierarchical list of UIDs from the instances
* that should be retrieved:
* A hierarchical list of UIDs is a regular JS Array where the type of the UID
* (study, series, instance) is determined by its nasting lavel. For example:
* @ The following list instructs the library to download all the instances
* from both studies "A" and "B":
*
* ['studyUIDFromA', 'studyUIDFromB']
*
* @ In the previous example both UIDs are treated as STUDY UIDs because both
* of them are listed in the same (top) level of the list. If, on the other
* hand, only instances from series "I" and "J" from the study "B"
* are to be downloaded, the expected hierarchical list would be:
*
* ['studyUIDFromA', ['studyUIDFromB', ['seriesUIDFromI', 'seriesUIDFromJ']]]
*
* @ Which, when prettified, reads like this:
*
* [
* 'studyUIDFromA',
* ['studyUIDFromB', [
* 'seriesUIDFromI',
* 'seriesUIDFromJ'
* ]]
* ]
*
* @ Furthermore, if only instances "X", "Y" and "Z" from series "J" need to
* be downloaded (instead of all the instances from that series), the list
* could be changed to:
*
* [
* 'studyUIDFromA',
* ['studyUIDFromB', [
* 'seriesUIDFromI',
* ['seriesUIDFromJ', [
* 'instanceUIDFromX',
* 'instanceUIDFromY',
* 'instanceUIDFromZ'
* ]]
* ]]
* ]
*
* Please refer to hierarchicalListUtils.js for more information and utilities;
*
* @param {Object} options A plain object with options;
* @param {function} options.progress A callback to retrieve notifications
* @returns {Promise} A promise that resolves to an URL from which the ZIP file
* can be downloaded;
*/
async function downloadAndZip(dicomWebClient, listOfUIDs, options) {
if (dicomWebClient instanceof api.DICOMwebClient) {
const settings = buildSettings(listOfUIDs, options);
const { compression } = settings.tasks;
// Register user-provided progress handler as a task list observer
progressUtils.addObserver(settings.taskList, settings.options.progress);
const buffers = await downloadAll(dicomWebClient, settings).catch(error => {
// Reject promise from compression task on download failure
compression.deferred.reject(error);
throw error;
});
compression.deferred.resolve(zipAll(buffers, settings));
const url = await compression.deferred.promise;
return url;
}
throw new Error('A valid DICOM Web Client instance is expected');
}
/**
* Utils
*/
async function zipAll(buffers, settings) {
const zip = new JSZip();
OHIF.log.info('Adding DICOM P10 files to archive:', buffers.length);
buffers.forEach((buffer, i) => {
const path = buildPath(buffer) || `${i}.dcm`;
zip.file(path, buffer);
});
// Set compression task progress to 50%
progressUtils.update(settings.tasks.compression.task, 0.5);
const blob = await zip.generateAsync({ type: 'blob' });
return URL.createObjectURL(blob);
}
function buildSettings(listOfUIDs, options) {
const taskList = progressUtils.createList();
const compression = progressUtils.addDeferred(taskList);
const downloads = [];
// Build downloads list
hierarchicalListUtils.forEach(
listOfUIDs,
(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) => {
if (isDicomUid(StudyInstanceUID)) {
downloads.push({
tracking: progressUtils.addDeferred(taskList),
parameters: [StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID],
});
}
}
);
// Print tree of hierarchical references
OHIF.log.info('Downloading DICOM P10 files for references:');
OHIF.log.info(hierarchicalListUtils.print(listOfUIDs));
return {
options: Object(options),
taskList,
tasks: {
downloads,
compression,
},
};
}
function buildPath(buffer) {
let path;
try {
const byteArray = new Uint8Array(buffer);
const dataSet = dicomParser.parseDicom(byteArray, {
// Stop parsing after SeriesInstanceUID is found
untilTag: 'x0020000e',
});
const StudyInstanceUID = dataSet.string('x0020000d');
const SeriesInstanceUID = dataSet.string('x0020000e');
const SOPInstanceUID = dataSet.string('x00080018');
if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) {
path = `${StudyInstanceUID}/${SeriesInstanceUID}/${SOPInstanceUID}.dcm`;
}
} catch (e) {
OHIF.log.error('Error parsing downloaded DICOM P10 file...', e);
}
return path;
}
async function downloadAll(dicomWebClient, settings) {
const { downloads } = settings.tasks;
// Make sure at least one download was initiated
if (downloads.length < 1) {
throw new Error('No valid reference to be downloaded');
}
const promises = downloads.map(item => {
const {
parameters,
tracking: { deferred, task },
} = item;
deferred.resolve(download(task, dicomWebClient, ...parameters));
return deferred.promise;
});
// Wait on created download promises
return Promise.all(promises).then(results => {
const buffers = [];
// The "results" array may directly contain buffers (ArrayBuffer instances)
// or arrays of buffers, depending on the type of downloads initiated on the
// previous step (retrieveStudy, retrieveSeries or retrieveinstance). Ex:
// results = [buf1, [buf2, buf3], buf4, [buf5], ...];
results.forEach(
function select(nesting, result) {
if (result instanceof ArrayBuffer) {
buffers.push(result);
} else if (nesting && Array.isArray(result)) {
// "nesting" argument is important to make sure only two levels
// of arrays are visited. For example, "bufX" should not be visited:
// [buf1, [buf2, buf3, [bufX]], buf4, [buf5], ...];
result.forEach(select.bind(null, false));
}
}.bind(null, true)
);
return buffers;
});
}
async function download(
task,
dicomWebClient,
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID
) {
// Strict DICOM-formatted variable names COULDN'T be used here because the
// DICOM Web client interface expects them in this specific format.
// @TODO: Add support for download progress handler which will use the
// currently not use "task" param
if (!isDicomUid(studyInstanceUID)) {
throw new Error('Download requires at least a "StudyInstanceUID" property');
}
if (!isDicomUid(seriesInstanceUID)) {
// Download entire study
return dicomWebClient.retrieveStudy({
studyInstanceUID,
});
}
if (!isDicomUid(sopInstanceUID)) {
// Download entire series
return dicomWebClient.retrieveSeries({
studyInstanceUID,
seriesInstanceUID,
});
}
// Download specific instance
return dicomWebClient.retrieveInstance({
studyInstanceUID,
seriesInstanceUID,
sopInstanceUID,
});
}
/**
* Exports
*/
export { downloadAndZip as default, downloadAndZip };
@@ -1,43 +0,0 @@
import { getDicomWebClientFromConfig } from './utils';
import { getCommands } from './commandsModule';
/**
* Constants
*/
/**
* Globals
*/
const sharedContext = {
dicomWebClient: null,
};
/**
* Extension
*/
export default {
/**
* Only required property. Should be a unique value across all extensions.
*/
id: 'dicom-p10-downloader',
/**
* LIFECYCLE HOOKS
*/
preRegistration({ appConfig }) {
const dicomWebClient = getDicomWebClientFromConfig(appConfig);
if (dicomWebClient) {
sharedContext.dicomWebClient = dicomWebClient;
}
},
/**
* MODULE GETTERS
*/
getCommandsModule() {
return getCommands(sharedContext);
},
};
@@ -1,110 +0,0 @@
import OHIF from '@ohif/core';
import { api } from 'dicomweb-client';
import { saveAs } from 'file-saver';
const {
utils: { isDicomUid, resolveObjectPath, hierarchicalListUtils },
DICOMWeb,
} = OHIF;
function validDicomUid(subject) {
if (isDicomUid(subject)) {
return subject;
}
}
function getActiveServerFromServersStore(store) {
const servers = resolveObjectPath(store, 'servers');
if (Array.isArray(servers) && servers.length > 0) {
return servers.find(server => resolveObjectPath(server, 'active') === true);
}
}
function getDicomWebClientFromConfig(config) {
const servers = resolveObjectPath(config, 'servers.dicomWeb');
if (Array.isArray(servers) && servers.length > 0) {
const server = servers[0];
return new api.DICOMwebClient({
url: server.wadoRoot,
headers: DICOMWeb.getAuthorizationHeader(server),
});
}
}
function getDicomWebClientFromContext(context, store) {
const activeServer = getActiveServerFromServersStore(store);
if (activeServer) {
return new api.DICOMwebClient({
url: activeServer.wadoRoot,
headers: DICOMWeb.getAuthorizationHeader(activeServer),
});
} else if (context.dicomWebClient instanceof api.DICOMwebClient) {
return context.dicomWebClient;
}
}
function getSOPInstanceReference(viewports, index) {
if (index >= 0) {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = Object(
resolveObjectPath(viewports, `viewportSpecificData.${index}`)
);
return Object.freeze(
hierarchicalListUtils.addToList(
[],
validDicomUid(StudyInstanceUID),
validDicomUid(SeriesInstanceUID),
validDicomUid(SOPInstanceUID)
)
);
}
}
function getSOPInstanceReferenceFromActiveViewport(viewports) {
return getSOPInstanceReference(
viewports,
resolveObjectPath(viewports, 'activeViewportIndex')
);
}
function getSOPInstanceReferencesFromViewports(viewports) {
const list = [];
const viewportSpecificData = resolveObjectPath(
viewports,
'viewportSpecificData'
);
Object.keys(viewportSpecificData).forEach(index => {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = Object(
viewportSpecificData[index]
);
hierarchicalListUtils.addToList(
list,
validDicomUid(StudyInstanceUID),
validDicomUid(SeriesInstanceUID),
validDicomUid(SOPInstanceUID)
);
});
return list;
}
function save(promise, listOfUIDs) {
return Promise.resolve(promise)
.then(url => {
OHIF.log.info('Files successfully compressed:', url);
const StudyInstanceUID = hierarchicalListUtils.getItem(listOfUIDs, 0);
saveAs(url, `${StudyInstanceUID}.zip`);
return url;
})
.catch(error => {
OHIF.log.error('Failed to create Zip file...', error);
return null;
});
}
export {
save,
validDicomUid,
getDicomWebClientFromConfig,
getDicomWebClientFromContext,
getSOPInstanceReferenceFromActiveViewport,
getSOPInstanceReferencesFromViewports,
};
@@ -1,8 +0,0 @@
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 });
};
@@ -1,38 +0,0 @@
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: 'OHIFExtLesionTracker',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
});
};
-22
View File
@@ -1,22 +0,0 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.2.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-lesion-tracker@0.1.0...@ohif/extension-lesion-tracker@0.2.0) (2020-02-10)
### Features
* Lesion tracker right panel ([#1428](https://github.com/OHIF/Viewers/issues/1428)) ([98a649b](https://github.com/OHIF/Viewers/commit/98a649b455ffc712938fc5035cdef40695e58440))
# 0.1.0 (2020-02-06)
### Features
* lesion-tracker extension ([#1420](https://github.com/OHIF/Viewers/issues/1420)) ([73e4409](https://github.com/OHIF/Viewers/commit/73e440968ce4699d081a9c9f2d21dd68095b3056))
-21
View File
@@ -1,21 +0,0 @@
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.
-38
View File
@@ -1,38 +0,0 @@
# @ohif/extension-lesion-tracker
This project is an OHIF extension that can be used with the Core OHIF Platform,
either at runtime, or as an ES6 dependency, to create a medical image viewing
application similar to that of the [legacy Lesion
Tracker][legacy-lesion-tracker] viewer.
## About
...
## Scope
...
### Configuration
...
### Extensions
...
## Build & Deploy
...
## Funding/Support
...
<!--
LINKS
-->
<!-- prettier-ignore-start -->
[legacy-lesion-tracker]: http://lesiontracker.ohif.org/studylist
<!-- prettier-ignore-end -->
-39
View File
@@ -1,39 +0,0 @@
{
"name": "@ohif/extension-lesion-tracker",
"version": "0.2.0",
"description": "OHIF extension for Lesion Tracker",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=10",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:lesion-tracker": "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.11.0",
"react-dom": "^16.11.0"
},
"dependencies": {
"@babel/runtime": "7.7.6",
"classnames": "^2.2.6"
}
}
@@ -1,60 +0,0 @@
.MeasurementComparisonTable {
height: 100%;
display: flex;
flex-direction: column;
}
.MeasurementComparisonTable .displayTexts {
display: flex;
}
.MeasurementComparisonTable .displayTexts .measurementDisplayText {
flex: 1;
}
.MeasurementComparisonTable .measurementTableHeader {
display: flex;
position: relative;
padding-left: 38px;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status {
left: 0;
top: 3px;
cursor: pointer;
width: 40px;
height: 100%;
display: flex;
align-items: center;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status .warning-border {
padding: 2px 3px;
width: 30px;
height: 30px;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status svg {
width: 20px;
height: 18px;
}
.MeasurementComparisonTable .measurementTableHeader .measurementTableHeaderItem {
position: relative;
flex: 1;
}
.MeasurementComparisonTable .generate-report {
background-color: #151a1f;
margin-top: 2px;
padding: 10px 0;
display: flex;
justify-content: center;
align-items: center;
}
.MeasurementComparisonTable .generate-report button {
font-size: 12px;
color: black;
background-color: var(--active-color);
}
@@ -1,151 +0,0 @@
import React from 'react';
import { MeasurementTable } from '@ohif/ui';
import './MeasurementComparisonTable.css';
const overallWarnings = {
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
};
const measurements = [
{
measurementId: '125',
measurementNumber: '125',
itemNumber: 1,
label: '(No description)',
data: [{ displayText: '12.5 x 4.6' }],
},
{
measurementId: '124',
measurementNumber: '124',
itemNumber: 2,
label: '(No description)',
data: [{ displayText: '32.5 x 1.6' }],
},
{
measurementId: '123',
measurementNumber: '123',
itemNumber: 3,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '5.5 x 9.2' }],
},
];
const additionalFindings = [
{
measurementId: '122',
measurementNumber: '122',
itemNumber: 1,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '23.5 x 9.2' }],
},
{
measurementId: '121',
measurementNumber: '121',
itemNumber: 2,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '11.2 x 9.2' }],
},
{
measurementId: '120',
measurementNumber: '120',
itemNumber: 3,
label: '(No description)',
data: [{ displayText: '2.9 x 9.2' }],
},
];
const currentCollections = [
{
selectorAction: () => {},
maxMeasurements: 3,
groupName: 'Measurements',
measurements: measurements,
},
{
selectorAction: () => {},
groupName: 'Additional Findings',
measurements: additionalFindings,
},
];
const comparisonColletions = [
{
selectorAction: () => {},
maxMeasurements: 3,
groupName: 'Measurements',
measurements: measurements,
},
{
selectorAction: () => {},
groupName: 'Additional Findings',
measurements: additionalFindings,
},
];
const comparisonCollections = currentCollections.map((group, index) => {
return {
...group,
measurements: group.measurements.map((measurement, measurementIndex) => {
const comparisonCollection = comparisonColletions[index].measurements;
if (measurementIndex < comparisonCollection.length) {
return {
...measurement,
data: [
...measurement.data,
...comparisonCollection[measurementIndex].data,
],
};
}
}),
};
});
const comparisonTimepoints = [
{
key: 'Current',
date: '10-Apr-18',
},
{
key: 'Comparison',
date: '15-Jun-18',
},
];
const MeasurementComparisonTable = () => {
return (
<div className="MeasurementComparisonTable">
<MeasurementTable
timepoints={comparisonTimepoints}
overallWarnings={overallWarnings}
measurementCollection={comparisonCollections}
onRelabelClick={() => {}}
onEditDescriptionClick={() => {}}
/>
<div className="generate-report">
<button className="btn btn-primary">Generate Report</button>
</div>
</div>
);
};
export default MeasurementComparisonTable;
-52
View File
@@ -1,52 +0,0 @@
import MeasurementComparisonTable from './components/MeasurementComparisonTable';
export default {
/**
* Only required property. Should be a unique value across all extensions.
*/
id: 'lesion-tracker',
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
*/
getPanelModule({ servicesManager, commandsManager }) {
return {
menuOptions: [
{
icon: 'th-list',
label: 'Measurements',
target: 'lesion-tracker-panel',
},
],
components: [
{
id: 'lesion-tracker-panel',
component: MeasurementComparisonTable,
},
],
defaultContext: ['VIEWER'],
};
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getToolbarModule() {
return null;
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getCommandsModule() {
return null;
},
};
@@ -0,0 +1,88 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { Machine } from 'xstate';
import { useMachine } from '@xstate/react';
import {
machineConfiguration,
defaultOptions,
} from './measurementTrackingMachine';
const TrackedMeasurementsContext = React.createContext();
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
const useTrackedMeasurements = () => useContext(TrackedMeasurementsContext);
/**
*
* @param {*} param0
*/
function TrackedMeasurementsContextProvider(
UIViewportDialogService,
{ children }
) {
function promptUser(message, ctx, evt) {
const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt;
return new Promise(function(resolve, reject) {
/**
* TODO: Will have issues if "SeriesInstanceUID" exists in multiple displaySets?
*
* @param {number} result - -1 | 0 | 1 --> deny | cancel | accept
* @return resolve { userResponse: number, StudyInstanceUID: string, SeriesInstanceUID: string }
*/
const handleSubmit = result => {
UIViewportDialogService.hide();
resolve({ userResponse: result, StudyInstanceUID, SeriesInstanceUID });
};
UIViewportDialogService.show({
viewportIndex,
type: 'info',
message,
actions: [
{ type: 'cancel', text: 'No', value: 0 },
{ type: 'secondary', text: 'No, do not ask again', value: -1 },
{ type: 'primary', text: 'Yes', value: 1 },
],
onSubmit: handleSubmit,
});
});
}
// Set StateMachine behavior for prompts (invoked services)
const machineOptions = Object.assign({}, defaultOptions);
machineOptions.services = Object.assign({}, machineOptions.services, {
promptBeginTracking: promptUser.bind(null, 'Start tracking?'),
promptTrackNewStudy: promptUser.bind(null, 'New study?'),
promptTrackNewSeries: promptUser.bind(null, 'New series?'),
});
const measurementTrackingMachine = Machine(
machineConfiguration,
machineOptions
);
const [
trackedMeasurements,
sendTrackedMeasurementsEvent,
trackedMeasurementsService,
] = useMachine(measurementTrackingMachine);
return (
<TrackedMeasurementsContext.Provider
value={[trackedMeasurements, sendTrackedMeasurementsEvent]}
>
{children}
</TrackedMeasurementsContext.Provider>
);
}
TrackedMeasurementsContextProvider.propTypes = {
children: PropTypes.oneOf([PropTypes.func, PropTypes.node]),
};
export {
TrackedMeasurementsContext,
TrackedMeasurementsContextProvider,
useTrackedMeasurements,
};
@@ -1,45 +1,5 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useMachine } from '@xstate/react';
import {
measurementTrackingMachine,
defaultOptions,
} from './measurementTrackingMachine';
const TrackedMeasurementsContext = React.createContext();
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
const useTrackedMeasurements = () => useContext(TrackedMeasurementsContext);
/**
*
* @param {*} param0
*/
function TrackedMeasurementsContextProvider({ children }) {
// TODO: Configure `UIViewportNotificationService` for appropriate actions
const measurementTrackingMachineOptions = Object.assign({}, defaultOptions);
const [
trackedMeasurements,
sendTrackedMeasurementsEvent,
trackedMeasurementsService,
] = useMachine(measurementTrackingMachine, measurementTrackingMachineOptions);
return (
<TrackedMeasurementsContext.Provider
value={[trackedMeasurements, sendTrackedMeasurementsEvent]}
>
{children}
</TrackedMeasurementsContext.Provider>
);
}
TrackedMeasurementsContextProvider.propTypes = {
children: PropTypes.oneOf([PropTypes.func, PropTypes.node]),
};
export {
TrackedMeasurementsContext,
TrackedMeasurementsContextProvider,
useTrackedMeasurements,
};
} from './TrackedMeasurementsContext.jsx';
@@ -1,124 +1,101 @@
import { Machine, assign } from 'xstate';
// import { assign } from '@xstate/immer';
import { assign } from 'xstate';
const machineConfiguration = {
id: 'measurementTracking',
initial: 'notTracking',
initial: 'idle',
context: {
prevTrackedStudy: '',
prevTrackedSeries: [],
trackedStudy: '',
trackedSeries: [],
promptResponse: 0,
},
states: {
notTracking: {
off: {
type: 'final',
},
idle: {
entry: 'clearContext',
on: {
TRACK_SERIES: {
target: 'awaitShouldTrackPrompt',
actions: 'trackSeries',
},
TRACK_SERIES: 'promptBeginTracking',
},
},
awaitShouldTrackPrompt: {
initial: 'prompt',
states: {
prompt: {
invoke: {
id: 'shouldTrackPrompt',
src: () => confirmDialog('Should we start tracking?'),
onDone: {
target: 'validateResponse',
actions: assign({ promptResponse: (ctx, evt) => evt.data }),
},
onError: {
target: 'validateResponse',
actions: assign({ promptResponse: (ctx, evt) => evt.data }),
},
promptBeginTracking: {
invoke: {
src: 'promptBeginTracking',
onDone: [
{
target: 'tracking',
actions: ['setTrackedStudyAndSeries'],
cond: 'promptAccepted',
},
},
validateResponse: {
on: {
'': [
{
target: '#measurementTracking.tracking',
cond: 'acceptResponse',
},
{
target: '#measurementTracking.neverTrack',
cond: 'rejectResponse',
},
{
target: '#measurementTracking.notTracking',
cond: 'cancelResponse',
},
],
{
target: 'off',
cond: 'promptDeclined',
},
{
target: 'idle',
},
],
onError: {
target: 'idle',
},
},
},
neverTrack: {
type: 'final',
},
tracking: {
on: {
TRACK_SERIES: [
{
target: 'tracking',
cond: 'hasNewSeriesForTrackedStudy',
actions: 'trackSeries',
target: 'promptTrackNewStudy',
cond: 'isNewStudy',
},
{
target: 'awaitShouldTrackNewStudyPrompt',
cond: 'willTrackNewStudy',
actions: ['setPrevTracked', 'clearTrackedSeries', 'trackSeries'],
target: 'promptTrackNewSeries',
cond: 'isNewSeries',
},
],
UNTRACK_SERIES: [
{
target: 'notTracking',
cond: 'willBeEmpty',
actions: 'untrackSeries',
target: 'tracking',
actions: ['removeTrackedSeries'],
cond: 'hasRemainingTrackedSeries',
},
{
target: 'tracking',
actions: 'untrackSeries',
target: 'idle',
},
],
},
},
awaitShouldTrackNewStudyPrompt: {
initial: 'prompt',
states: {
prompt: {
invoke: {
id: 'shouldTrackPrompt',
src: () => confirmDialog('Should we track new study?'),
onDone: {
target: 'validateResponse',
actions: assign({ promptResponse: (ctx, evt) => evt.data }),
},
onError: {
target: 'validateResponse',
actions: assign({ promptResponse: (ctx, evt) => evt.data }),
},
promptTrackNewStudy: {
invoke: {
src: 'promptTrackNewStudy',
onDone: [
{
target: 'tracking',
actions: ['setTrackedStudyAndSeries'],
cond: 'promptAccepted',
},
{
target: 'tracking',
},
],
onError: {
target: 'idle',
},
validateResponse: {
on: {
'': [
{
target: '#measurementTracking.tracking',
cond: 'acceptResponse',
},
{
target: '#measurementTracking.tracking',
cond: 'rejectResponse',
actions: 'restorePrevTracked',
},
],
},
},
promptTrackNewSeries: {
invoke: {
src: 'promptTrackNewSeries',
onDone: [
{
target: 'tracking',
actions: ['addTrackedSeries'],
cond: 'promptAccepted',
},
{
target: 'tracking',
},
],
onError: {
target: 'idle',
},
},
},
@@ -126,71 +103,59 @@ const machineConfiguration = {
strict: true,
};
function confirmDialog(msg) {
return new Promise(function(resolve, reject) {
let confirmed = window.confirm(msg);
return confirmed ? resolve(1) : reject(-1);
});
}
const defaultOptions = {
services: {
promptBeginTracking: (ctx, evt) => {
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
},
promptTrackNewStudy: (ctx, evt) => {
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
},
promptTrackNewSeries: (ctx, evt) => {
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
},
},
actions: {
clearContext: assign({
prevTrackedStudy: '',
prevTrackedSeries: [],
trackedStudy: '',
trackedSeries: [],
promptResponse: 0,
}),
setPrevTracked: assign(ctx => ({
prevTrackedStudy: ctx.trackedStudy,
prevTrackedSeries: ctx.trackedSeries.slice(),
// Promise resolves w/ `evt.data.*`
setTrackedStudyAndSeries: assign((ctx, evt) => ({
trackedStudy: evt.data.StudyInstanceUID,
trackedSeries: [evt.data.SeriesInstanceUID],
})),
restorePrevTracked: assign(ctx => ({
trackedStudy: ctx.prevTrackedStudy,
trackedSeries: ctx.prevTrackedSeries.slice(),
addTrackedSeries: assign((ctx, evt) => ({
trackedSeries: [...ctx.trackedSeries, evt.data.SeriesInstanceUID],
})),
clearTrackedSeries: assign(() => ({
trackedStudy: '',
trackedSeries: [],
removeTrackedSeries: assign((ctx, evt) => ({
trackedSeries: ctx.trackedSeries
.slice()
.filter(ser => ser !== evt.SeriesInstanceUID),
})),
trackSeries: assign((ctx, evt) => {
const prevTrackedSeries = ctx.trackedSeries.slice();
return {
trackedStudy: evt.StudyInstanceUID || '',
trackedSeries: [...prevTrackedSeries, evt.SeriesInstanceUID],
};
}),
untrackSeries: assign((ctx, evt) => {
const prevTrackedSeries = ctx.trackedSeries.slice();
return {
trackedSeries: prevTrackedSeries.filter(
serUid => serUid !== evt.SeriesInstanceUID
),
};
}),
},
guards: {
hasNewSeriesForTrackedStudy: (ctx, evt) =>
evt.StudyInstanceUID === ctx.trackedStudy &&
promptAccepted: (ctx, evt) => evt.data && evt.data.userResponse === 1,
promptCanceled: (ctx, evt) => evt.data && evt.data.userResponse === 0,
promptDeclined: (ctx, evt) => evt.data && evt.data.userResponse === -1,
// Has more than 1, or SeriesInstanceUID is not in list
// --> Post removal would have non-empty trackedSeries array
hasRemainingTrackedSeries: (ctx, evt) =>
ctx.trackedSeries.length > 1 ||
!ctx.trackedSeries.includes(evt.SeriesInstanceUID),
isNewStudy: (ctx, evt) => ctx.trackedStudy !== evt.StudyInstanceUID,
isNewSeries: (ctx, evt) =>
!ctx.trackedSeries.includes(evt.SeriesInstanceUID),
willTrackNewStudy: ctx => ctx.StudyInstanceUID !== ctx.trackedStudy,
willBeEmpty: (ctx, evt) =>
evt.SeriesInstanceUID &&
ctx.trackedSeries.length === 1 &&
ctx.trackedSeries[0] === evt.SeriesInstanceUID,
acceptResponse: ctx => ctx.promptResponse === 1,
cancelResponse: ctx => ctx.promptResponse === 0,
rejectResponse: ctx => ctx.promptResponse === -1,
},
};
const measurementTrackingMachine = Machine(machineConfiguration, defaultOptions);
// const measurementTrackingMachine = Machine(
// machineConfiguration,
// defaultOptions
// );
// .transition(state, eventArgument).value
// const service = interpret(measurementTrackingMachine).start();
// .send(event): nextState
// .state (getter)
// .onTransition(state => { state.vale })
export { defaultOptions, machineConfiguration, measurementTrackingMachine };
export default measurementTrackingMachine;
export { defaultOptions, machineConfiguration };
@@ -10,7 +10,9 @@ function getCommandsModule({ servicesManager }) {
toggleLayoutSelectionDialog: {
commandFn: () => {
if (!UIDialogService) {
window.alert('Unable to show dialog; no UI Dialog Service available.');
window.alert(
'Unable to show dialog; no UI Dialog Service available.'
);
return;
}
@@ -26,13 +28,12 @@ function getCommandsModule({ servicesManager }) {
showOverlay: true,
content: Test,
});
},
storeContexts: [],
options: {},
context: 'VIEWER',
},
}
};
return {
definitions,
@@ -46,19 +47,22 @@ function Test() {
dispatch,
] = useViewportGrid();
return <div
onClick={() => {
dispatch({ type: '', payload: {
numCols: 2,
numRows: 2,
activeViewportIndex: 0,
viewports: [],
}})
}}
style={{ color: 'white' }}
>
Hello World!
</div>
return (
<div
onClick={() => {
dispatch({
type: 'SET_LAYOUT',
payload: {
numCols: 2,
numRows: 2,
},
});
}}
style={{ color: 'white' }}
>
Hello World!
</div>
);
}
export default getCommandsModule;
@@ -4,12 +4,18 @@ import {
useTrackedMeasurements,
} from './contexts';
function getContextModule() {
function getContextModule({ servicesManager }) {
const { UIViewportDialogService } = servicesManager.services;
const BoundTrackedMeasurementsContextProvider = TrackedMeasurementsContextProvider.bind(
null,
UIViewportDialogService
);
return [
{
name: 'TrackedMeasurementsContext',
context: TrackedMeasurementsContext,
provider: TrackedMeasurementsContextProvider,
provider: BoundTrackedMeasurementsContextProvider,
},
];
}
@@ -25,7 +25,7 @@ function getPanelModule({
}),
},
{
name: 'measure',
name: 'trackedMeasurements',
iconName: 'list-bullets',
iconLabel: 'Measure',
label: 'Measurements',
@@ -1,7 +1,7 @@
import React from 'react';
const Component = React.lazy(() => {
return import('./viewports/OHIFCornerstoneViewport');
return import('./viewports/TrackedCornerstoneViewport');
});
const OHIFCornerstoneViewport = props => {
@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, ButtonGroup, Icon, IconButton } from '@ohif/ui';
function ActionButtons() {
return (
<React.Fragment>
<ButtonGroup onClick={() => alert('Export')}>
<Button
className="px-2 py-2 text-base text-white bg-black border-primary-main"
size="initial"
color="inherit"
>
Export
</Button>
<IconButton
className="px-2 text-white bg-black border-primary-main"
color="inherit"
size="initial"
>
<Icon name="arrow-down" />
</IconButton>
</ButtonGroup>
<Button
className="px-2 py-2 ml-2 text-base text-white bg-black border border-primary-main"
variant="outlined"
size="initial"
color="inherit"
onClick={() => alert('Create Report')}
>
Create Report
</Button>
</React.Fragment>
);
}
export default ActionButtons;
@@ -1,74 +1,202 @@
import React from 'react';
import {
MeasurementsPanel,
Button,
ButtonGroup,
Icon,
IconButton,
} from '@ohif/ui';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { StudySummary, MeasurementTable } from '@ohif/ui';
import { DicomMetadataStore } from '@ohif/core';
import { useDebounce } from '@hooks';
import ActionButtons from './ActionButtons';
import { useTrackedMeasurements } from '../../getContextModule';
export default function MeasurementTable({ servicesManager, commandsManager }) {
const { MeasurementService } = servicesManager.services;
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
key: undefined, //
date: undefined, // '07-Sep-2010',
modality: undefined, // 'CT',
description: undefined, // 'CHEST/ABD/PELVIS W CONTRAST',
};
console.log('MeasurementTable rendering!!!!!!!!!!!!!');
const actionButtons = (
<React.Fragment>
<ButtonGroup onClick={() => alert('Export')}>
<Button
className="px-2 py-2 text-base text-white bg-black border-primary-main"
size="initial"
color="inherit"
>
Export
</Button>
<IconButton
className="px-2 text-white bg-black border-primary-main"
color="inherit"
size="initial"
>
<Icon name="arrow-down" />
</IconButton>
</ButtonGroup>
<Button
className="px-2 py-2 ml-2 text-base text-white bg-black border border-primary-main"
variant="outlined"
size="initial"
color="inherit"
onClick={() => alert('Create Report')}
>
Create Report
</Button>
</React.Fragment>
function PanelMeasurementTableTracking({ servicesManager, commandsManager }) {
const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(
Date.now().toString()
);
const debouncedMeasurementChangeTimestamp = useDebounce(
measurementChangeTimestamp,
200
);
const { MeasurementService } = servicesManager.services;
const [
trackedMeasurements,
sendTrackedMeasurementsEvent,
] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState(
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
);
const [displayMeasurements, setDisplayMeasurements] = useState([]);
// TODO: measurements subscribtion
const descriptionData = {
date: '07-Sep-2010',
modality: 'CT',
description: 'CHEST/ABD/PELVIS W CONTRAST',
};
// Initial?
useEffect(() => {
const measurements = MeasurementService.getMeasurements();
const filteredMeasurements = measurements.filter(
m =>
trackedStudy === m.referenceStudyUID &&
trackedSeries.includes(m.referenceSeriesUID)
);
const mappedMeasurements = filteredMeasurements.map((m, index) =>
_mapMeasurementToDisplay(m, index)
);
setDisplayMeasurements(mappedMeasurements);
// eslint-ignore-next-line
}, [
MeasurementService,
trackedStudy,
trackedSeries,
debouncedMeasurementChangeTimestamp,
]);
// ~~ DisplayStudySummary
useEffect(() => {
if (trackedMeasurements.matches('tracking')) {
const StudyInstanceUID = trackedStudy;
const studyMeta = DicomMetadataStore.getStudy(StudyInstanceUID);
const instanceMeta = studyMeta.series[0].instances[0];
const { Modality, StudyDate, StudyDescription } = instanceMeta;
if (displayStudySummary.key !== StudyInstanceUID) {
setDisplayStudySummary({
key: StudyInstanceUID,
date: StudyDate, // TODO: Format: '07-Sep-2010'
modality: Modality,
description: StudyDescription,
});
}
} else if (trackedStudy === '' || trackedStudy === undefined) {
setDisplayStudySummary(DISPLAY_STUDY_SUMMARY_INITIAL_VALUE);
}
}, [displayStudySummary.key, trackedMeasurements, trackedStudy]);
// TODO: Better way to consolidated, debounce, check on change?
// Are we exposing the right API for measurementService?
// This watches for ALL MeasurementService changes. It updates a timestamp,
// which is debounced. After a brief period of inactivity, this triggers
// a re-render where we grab up-to-date measurements.
useEffect(() => {
const added = MeasurementService.EVENTS.MEASUREMENT_ADDED;
const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED;
const subscriptions = [];
[added, updated, removed].forEach(evt => {
subscriptions.push(
MeasurementService.subscribe(evt, () => {
setMeasurementsUpdated(Date.now().toString());
})
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, [MeasurementService, sendTrackedMeasurementsEvent]);
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 (
<MeasurementsPanel
descriptionData={descriptionData}
measurementTableData={measurementTableData}
actionButtons={actionButtons}
/>
<>
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
{displayStudySummary.key && (
<StudySummary
date={displayStudySummary.date}
modality={displayStudySummary.modality}
description={displayStudySummary.description}
/>
)}
<MeasurementTable
title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements}
onClick={() => {}}
onEdit={id => alert(`Edit: ${id}`)}
/>
</div>
<div className="flex justify-center p-4">
<ActionButtons />
</div>
</>
);
}
PanelMeasurementTableTracking.propTypes = {};
// TODO: This could be a MeasurementService mapper
function _mapMeasurementToDisplay(measurement, index) {
const {
id,
label,
description,
// Reference IDs
referenceStudyUID,
referenceSeriesUID,
SOPInstanceUID,
} = measurement;
const instance = DicomMetadataStore.getInstance(
referenceStudyUID,
referenceSeriesUID,
SOPInstanceUID
);
const { PixelSpacing, SeriesNumber, InstanceNumber } = instance;
console.log('mapping....', measurement);
console.log(instance);
return {
id: index + 1,
label: '(empty)', // 'Label short description',
displayText: _getDisplayText(
measurement.points,
PixelSpacing,
SeriesNumber,
InstanceNumber
),
// TODO: handle one layer down
isActive: false, // activeMeasurementItem === i + 1,
};
}
/**
*
* @param {*} points
* @param {*} pixelSpacing
*/
function _getDisplayText(points, pixelSpacing, seriesNumber, instanceNumber) {
// TODO: determination of shape influences text
// Length: 'xx.x unit (S:x, I:x)'
// Rectangle: 'xx.x x xx.x unit (S:x, I:x)',
// Ellipse?
// Bidirectional?
// Freehand?
const hasPixelSpacing =
pixelSpacing !== undefined &&
Array.isArray(pixelSpacing) &&
pixelSpacing.length === 2;
const [rowPixelSpacing, colPixelSpacing] = hasPixelSpacing
? pixelSpacing
: [1, 1];
const unit = hasPixelSpacing ? 'mm' : 'px';
const { x: x1, y: y1 } = points[0];
const { x: x2, y: y2 } = points[1];
const dx = (x2 - x1) * colPixelSpacing;
const dy = (y2 - y1) * rowPixelSpacing;
const length = _round(Math.sqrt(dx * dx + dy * dy), 1);
return `${length} ${unit} (S:${seriesNumber}, I:${instanceNumber})`;
}
function _round(value, decimals) {
return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
}
export default PanelMeasurementTableTracking;
@@ -19,12 +19,14 @@ function PanelStudyBrowserTracking({
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> Studies --> DisplaySets --> Thumbnails
const [{ StudyInstanceUIDs }, dispatchImageViewer] = useImageViewer();
const [{ viewports }, dispatchViewportGrid] = useViewportGrid();
const [
{ activeViewportIndex, viewports },
dispatchViewportGrid,
] = useViewportGrid();
const [
trackedMeasurements,
sendTrackedMeasurementsEvent,
] = useTrackedMeasurements();
console.warn('trackedMeasurementsState: ', trackedMeasurements);
const [activeTabName, setActiveTabName] = useState('primary');
const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState(
[]
@@ -33,7 +35,7 @@ function PanelStudyBrowserTracking({
const [displaySets, setDisplaySets] = useState([]);
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
// TODO: Listen for measurement service "adds" (really shouldn't be added until cornerstone-tools "complete")
// TODO: Should this be somewhere else? Feels more like a mode "lifecycle" setup/destroy?
useEffect(() => {
const { unsubscribe } = MeasurementService.subscribe(
MeasurementService.EVENTS.MEASUREMENT_ADDED,
@@ -44,17 +46,15 @@ function PanelStudyBrowserTracking({
} = measurement;
sendTrackedMeasurementsEvent('TRACK_SERIES', {
viewportIndex: activeViewportIndex,
StudyInstanceUID,
SeriesInstanceUID,
});
console.log('PANEL:', measurement);
// console.log('Mapped:', annotation);
}
);
return unsubscribe;
}, [MeasurementService, sendTrackedMeasurementsEvent]);
}, [MeasurementService, activeViewportIndex, sendTrackedMeasurementsEvent]);
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
@@ -156,7 +156,7 @@ function PanelStudyBrowserTracking({
changedDisplaySets,
thumbnailImageSrcMap,
trackedSeries,
viewports,
viewports
);
setDisplaySets(mappedDisplaySets);
@@ -188,8 +188,11 @@ function PanelStudyBrowserTracking({
StudyInstanceUID
);
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
// eslint-disable-next-line prettier/prettier
? [...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
? [
...expandedStudyInstanceUIDs.filter(
stdyUid => stdyUid !== StudyInstanceUID
),
]
: [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
@@ -208,6 +211,16 @@ function PanelStudyBrowserTracking({
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
onClickUntrack={displaySetInstanceUID => {
const displaySet = DisplaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
// TODO: shift this somewhere else where we're centralizing this logic?
// Potentially a helper from displaySetInstanceUID to this
sendTrackedMeasurementsEvent('UNTRACK_SERIES', {
SeriesInstanceUID: displaySet.SeriesInstanceUID,
});
}}
/>
);
}
@@ -3,12 +3,16 @@ import PropTypes from 'prop-types';
import cornerstone from 'cornerstone-core';
import CornerstoneViewport from 'react-cornerstone-viewport';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import { ViewportActionBar, useViewportGrid } from '@ohif/ui';
import {
Notification,
ViewportActionBar,
useViewportGrid,
useViewportDialog,
} from '@ohif/ui';
import debounce from 'lodash.debounce';
import throttle from 'lodash.throttle';
import { useTrackedMeasurements } from './../getContextModule';
// const cine = viewportSpecificData.cine;
// isPlaying = cine.isPlaying === true;
@@ -16,7 +20,7 @@ import { useTrackedMeasurements } from './../getContextModule';
const { StackManager } = OHIF.utils;
function OHIFCornerstoneViewport({
function TrackedCornerstoneViewport({
children,
dataSource,
displaySet,
@@ -24,6 +28,8 @@ function OHIFCornerstoneViewport({
}) {
const [trackedMeasurements] = useTrackedMeasurements();
const [{ viewports }, dispatchViewportGrid] = useViewportGrid();
// viewportIndex, onSubmit
const [viewportDialogState, viewportDialogApi] = useViewportDialog();
const [viewportData, setViewportData] = useState(null);
// TODO: Still needed? Better way than import `OHIF` and destructure?
// Why is this managed by `core`?
@@ -98,7 +104,7 @@ function OHIFCornerstoneViewport({
// );
// TODO: This display contains the meta for all instances.
// That can't be right...
console.log('DISPLAYSET', displaySet)
// console.log('DISPLAYSET', displaySet);
// const seriesMeta = DicomMetadataStore.getSeries(this.props.displaySet.StudyInstanceUID, '');
// console.log(seriesMeta);
@@ -145,22 +151,36 @@ function OHIFCornerstoneViewport({
},
}}
/>
<CornerstoneViewport
viewportIndex={viewportIndex}
imageIds={imageIds}
imageIdIndex={currentImageIdIndex}
// TODO: ViewportGrid Context?
isActive={true} // todo
isStackPrefetchEnabled={true} // todo
isPlaying={false}
frameRate={24}
/>
{childrenWithProps}
{/* TODO: Viewport interface to accept stack or layers of content like this? */}
<div className="relative flex flex-row w-full h-full">
<CornerstoneViewport
viewportIndex={viewportIndex}
imageIds={imageIds}
imageIdIndex={currentImageIdIndex}
// TODO: ViewportGrid Context?
isActive={true} // todo
isStackPrefetchEnabled={true} // todo
isPlaying={false}
frameRate={24}
isOverlayVisible={false}
/>
<div className="absolute w-full">
{viewportDialogState.viewportIndex === viewportIndex && (
<Notification
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
/>
)}
</div>
{childrenWithProps}
</div>
</>
);
}
OHIFCornerstoneViewport.propTypes = {
TrackedCornerstoneViewport.propTypes = {
displaySet: PropTypes.object.isRequired,
viewportIndex: PropTypes.number.isRequired,
dataSource: PropTypes.object,
@@ -168,7 +188,7 @@ OHIFCornerstoneViewport.propTypes = {
customProps: PropTypes.object,
};
OHIFCornerstoneViewport.defaultProps = {
TrackedCornerstoneViewport.defaultProps = {
customProps: {},
};
@@ -207,6 +227,6 @@ async function _getViewportData(dataSource, displaySet) {
};
return viewportData;
};
}
export default OHIFCornerstoneViewport;
export default TrackedCornerstoneViewport;