First pass at a Hanging Protocol implementation and editor (LT-131)
This commit is contained in:
1 parent
dfcb33153f
commit
2e52d9e3c1
157 files changed
+8869
-23585
No files matched your search
@@ -0,0 +1,17 @@
|
||||
<template name="progressDialog">
|
||||
<div id="progressDialog" tabindex="0">
|
||||
<div class="dialogHeader">
|
||||
<h5>{{progressDialogTitle}}</h5>
|
||||
</div>
|
||||
<div class="dialogContent">
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width:{{progressStatus}}">
|
||||
{{progressStatus}}
|
||||
</div>
|
||||
</div>
|
||||
<div id="message">
|
||||
{{{progressMessage}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
progressDialog = {
|
||||
'show': function(title, numberOfTotal) {
|
||||
Session.set("progressDialogSettings", { title: title, numberOfCompleted: 0, numberOfTotal: numberOfTotal });
|
||||
|
||||
$('#progressDialog').css('display', 'block');
|
||||
},
|
||||
'update': function(numberOfCompleted) {
|
||||
var progressDialogSettings = Session.get("progressDialogSettings");
|
||||
progressDialogSettings.numberOfCompleted = numberOfCompleted;
|
||||
|
||||
Session.set("progressDialogSettings", progressDialogSettings);
|
||||
|
||||
if (progressDialogSettings.numberOfCompleted === progressDialogSettings.numberOfTotal) {
|
||||
progressDialog.close();
|
||||
}
|
||||
},
|
||||
'close': function() {
|
||||
Session.set("progressDialogSettings", { title: "", numberOfCompleted: 0, numberOfTotal: 1 });
|
||||
$('#progressDialog').css('display', 'none');
|
||||
},
|
||||
'setMessage': function(message) {
|
||||
var progressDialogSettings = Session.get("progressDialogSettings");
|
||||
progressDialogSettings.message = message;
|
||||
Session.set("progressDialogSettings", progressDialogSettings);
|
||||
}
|
||||
};
|
||||
|
||||
Template.progressDialog.helpers({
|
||||
'progressDialogTitle': function () {
|
||||
if (Session.get("progressDialogSettings") && Session.get("progressDialogSettings").title) {
|
||||
return Session.get("progressDialogSettings").title;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
'progressStatus': function() {
|
||||
var numberOfCompleted = 0;
|
||||
if (Session.get("progressDialogSettings") && Session.get("progressDialogSettings").numberOfCompleted) {
|
||||
numberOfCompleted = Session.get("progressDialogSettings").numberOfCompleted;
|
||||
}
|
||||
|
||||
var numberofTotal = 1;
|
||||
if (Session.get("progressDialogSettings") && Session.get("progressDialogSettings").numberOfTotal) {
|
||||
numberofTotal = Session.get("progressDialogSettings").numberOfTotal;
|
||||
}
|
||||
|
||||
return parseInt((numberOfCompleted / numberofTotal) * 100) + "%";
|
||||
},
|
||||
'progressMessage': function() {
|
||||
var progressDialogSettings = Session.get("progressDialogSettings");
|
||||
if (progressDialogSettings && progressDialogSettings.message) {
|
||||
return progressDialogSettings.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
#progressDialog
|
||||
display: none
|
||||
position: absolute
|
||||
top: 0
|
||||
bottom: 0
|
||||
left: 0
|
||||
right: 0
|
||||
margin: auto
|
||||
width: 20%
|
||||
height: 20%
|
||||
z-index: 100
|
||||
border-radius: 5px
|
||||
padding: 10px 20px 10px 20px
|
||||
background-color: rgba(255,255,255,1)
|
||||
outline: none
|
||||
|
||||
.dialogContent
|
||||
margin-bottom: 20px
|
||||
|
||||
#message
|
||||
width: 100%
|
||||
|
||||
span
|
||||
display: inline-block;
|
||||
width: 100%
|
||||
@@ -20,10 +20,20 @@ WorklistStudies = new Meteor.Collection(null);
|
||||
WorklistStudies._debugName = 'WorklistStudies';
|
||||
|
||||
Template.worklist.onRendered(function() {
|
||||
// If there is a tab set as active in the Session,
|
||||
// switch to that now.
|
||||
var contentId = Session.get('activeContentId');
|
||||
switchToTab(contentId);
|
||||
var templateData = Template.currentData();
|
||||
if (templateData && templateData.studyInstanceUid) {
|
||||
var studyInstanceUid = templateData.studyInstanceUid;
|
||||
openNewTab(studyInstanceUid, studyInstanceUid);
|
||||
} else {
|
||||
// If there is a tab set as active in the Session,
|
||||
// switch to that now.
|
||||
var contentId = Session.get('activeContentId');
|
||||
|
||||
// TODO: FIx this it seems to be forcing two switches
|
||||
switchToTab(contentId);
|
||||
}
|
||||
|
||||
Meteor.subscribe('hangingprotocols');
|
||||
});
|
||||
|
||||
Template.worklist.helpers({
|
||||
|
||||
@@ -97,5 +97,4 @@
|
||||
{{>studyNotFound}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -164,7 +164,7 @@ function search() {
|
||||
});
|
||||
|
||||
if (WorklistStudies.find().count() === 0) {
|
||||
// Show notFound text
|
||||
// Show studyNotFound text
|
||||
searchResults.showNotFoundMessage = true;
|
||||
Session.set('searchResults', searchResults);
|
||||
}
|
||||
|
||||
@@ -104,4 +104,4 @@
|
||||
border-top: 0.5px dashed #dddddd
|
||||
border-bottom: 0.5px dashed #dddddd
|
||||
color: white
|
||||
background-color: #009BD2
|
||||
background-color: #009BD2
|
||||
@@ -0,0 +1,115 @@
|
||||
var exportFailed;
|
||||
|
||||
/**
|
||||
* Exports requested studies
|
||||
* @param studiesToExport Studies to export
|
||||
*/
|
||||
exportStudies = function(studiesToExport) {
|
||||
if (studiesToExport.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryStudies(studiesToExport, exportQueriedStudies);
|
||||
};
|
||||
|
||||
function exportQueriedStudies(studiesToExport) {
|
||||
var numberOfFilesToExport = 0;
|
||||
studiesToExport.forEach(function(study) {
|
||||
numberOfFilesToExport += getNumberOfFilesInStudy(study);
|
||||
});
|
||||
|
||||
progressDialog.show("Exporting Studies...", numberOfFilesToExport);
|
||||
|
||||
try {
|
||||
exportQueriedStudiesInternal(studiesToExport, numberOfFilesToExport);
|
||||
} catch (err) {
|
||||
progressDialog.close();
|
||||
console.error("Failed to export studies: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function exportQueriedStudiesInternal(studiesToExport, numberOfFilesToExport) {
|
||||
var zip = new JSZip();
|
||||
|
||||
exportFailed = false;
|
||||
var numberOfFilesExported = 0;
|
||||
|
||||
studiesToExport.forEach(function(study) {
|
||||
sortStudy(study);
|
||||
|
||||
var studyFolder = zip.folder(study.studyInstanceUid);
|
||||
|
||||
study.seriesList.forEach(function(series) {
|
||||
var seriesFolder = studyFolder.folder(series.seriesInstanceUid);
|
||||
|
||||
series.instances.forEach(function(instance) {
|
||||
if (!instance.wadouri) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If failed to download a dicom file, skip others
|
||||
if (exportFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Download and Zip the dicom file
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", instance.wadouri, true);
|
||||
xhr.responseType = "blob";
|
||||
|
||||
// Downloaded the dicom file completely
|
||||
xhr.onload = function(e) {
|
||||
// If failed to download a dicom file, skip others
|
||||
if (exportFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Failed to export a file
|
||||
if (xhr.readyState === 4 && xhr.status !== 200) {
|
||||
onExportFailed("File not downloaded: " + instance.wadouri);
|
||||
return;
|
||||
}
|
||||
|
||||
var blobFile = new Blob([xhr.response], {type: 'application/dicom'});
|
||||
|
||||
var fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = function() {
|
||||
try {
|
||||
seriesFolder.file(instance.sopInstanceUid + ".dcm", fileReader.result, { binary: true });
|
||||
} catch(err) {
|
||||
onExportFailed(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
numberOfFilesExported++;
|
||||
|
||||
if (numberOfFilesExported === numberOfFilesToExport) {
|
||||
var zipContent = zip.generate({ type: "blob" });
|
||||
saveAs(zipContent, "studies.zip");
|
||||
}
|
||||
|
||||
progressDialog.update(numberOfFilesExported);
|
||||
};
|
||||
|
||||
fileReader.readAsArrayBuffer(blobFile);
|
||||
};
|
||||
|
||||
// Failed to download the dicom file
|
||||
xhr.onerror = function() {
|
||||
onExportFailed("File not downloaded: " + instance.wadouri);
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onExportFailed(err) {
|
||||
exportFailed = true;
|
||||
progressDialog.close();
|
||||
|
||||
//TODO: Export failed and dialog closed, so let user know
|
||||
console.error("Failed to export studies!", err);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Retrieves metaData for multiple studies at once.
|
||||
*
|
||||
* This function calls getStudyMetadata several times, asynchronously,
|
||||
* and waits for all of the results to be returned.
|
||||
*
|
||||
* @param studyInstanceUids The UIDs of the Studies to be retrieved
|
||||
* @param doneCallback The callback function to be executed when the study retrieval has finished
|
||||
* @param failCallback The callback function to be executed when the study retrieval has failed
|
||||
*/
|
||||
getStudiesMetadata = function(studyInstanceUids, doneCallback, failCallback) {
|
||||
// Check to make sure studyInstanceUids were actually input
|
||||
if (!studyInstanceUids || !studyInstanceUids.length) {
|
||||
if (failCallback && typeof failCallback === 'function') {
|
||||
failCallback('No studyInstanceUids were input');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an empty array to store the Promises for each metaData
|
||||
// retrieval call
|
||||
var promises = [];
|
||||
|
||||
// Loop through the array of studyInstanceUids
|
||||
studyInstanceUids.forEach(function(studyInstanceUid) {
|
||||
// Create a new Deferred to monitor the progress of the asynchronous
|
||||
// metaData retrieval
|
||||
var deferred = new $.Deferred();
|
||||
|
||||
// Send the call, and attach doneCallbacks and failCallbacks
|
||||
// which can resolve or reject the related promise based on its outcome
|
||||
getStudyMetadata(studyInstanceUid, function(study) {
|
||||
deferred.resolve(study);
|
||||
}, function(error) {
|
||||
deferred.reject(error);
|
||||
});
|
||||
|
||||
// Add the current promise to the array of promises
|
||||
promises.push(deferred.promise());
|
||||
});
|
||||
|
||||
// When all of the promises are complete, this callback runs
|
||||
$.when.apply($, promises).done(function() {
|
||||
// Convert the Arguments Array-like Object to an actual array
|
||||
var studies = $.makeArray(arguments);
|
||||
|
||||
// Pass the studies array to the doneCallback, if one exists
|
||||
if (doneCallback && typeof doneCallback === 'function') {
|
||||
doneCallback(studies);
|
||||
}
|
||||
}).fail(function(error) {
|
||||
log.warn(error);
|
||||
if (failCallback && typeof failCallback === 'function') {
|
||||
failCallback(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// Define the StudyMetaData object. This is used as a cache
|
||||
// to store study meta data information to prevent unnecessary
|
||||
// calls to the server
|
||||
var StudyMetaData = {};
|
||||
|
||||
/**
|
||||
* Retrieves study metadata using a server call, and fires a callback
|
||||
* when completed.
|
||||
*
|
||||
* @params {string} studyInstanceUid The UID of the Study to be retrieved
|
||||
* @params {function} doneCallback The callback function to be executed when the study retrieval has finished
|
||||
* @param failCallback The callback function to be executed when the study retrieval has failed
|
||||
*/
|
||||
getStudyMetadata = function(studyInstanceUid, doneCallback, failCallback) {
|
||||
// If the StudyMetaData cache already has data related to this
|
||||
// studyInstanceUid, then we should fire the doneCallback with this data
|
||||
// and stop here.
|
||||
var study = StudyMetaData[studyInstanceUid];
|
||||
if (study) {
|
||||
doneCallback(study);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no study metadata is in the cache variable, we need to retrieve it from
|
||||
// the server with a call.
|
||||
Meteor.call('GetStudyMetadata', studyInstanceUid, function(error, study) {
|
||||
if (Meteor.user && Meteor.user()) {
|
||||
var hipaaEvent = {
|
||||
eventType: 'viewed',
|
||||
userId: Meteor.userId(),
|
||||
userName: Meteor.user().profile.fullName,
|
||||
collectionName: 'Study',
|
||||
recordId: studyInstanceUid,
|
||||
patientId: study.patientId,
|
||||
patientName: study.patientName
|
||||
};
|
||||
HipaaLogger.logEvent(hipaaEvent);
|
||||
}
|
||||
|
||||
log.info('worklistStudy getStudyMetadata: ' + studyInstanceUid);
|
||||
|
||||
if (error) {
|
||||
log.warn(error);
|
||||
failCallback(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once we have retrieved the data, we sort the series' by series
|
||||
// and instance number in ascending order
|
||||
sortStudy(study);
|
||||
|
||||
// Then we store this data in the cache variable
|
||||
StudyMetaData[studyInstanceUid] = study;
|
||||
|
||||
// Finally, we fire the doneCallback with this study meta data
|
||||
doneCallback(study);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Opens a new tab in the tabbed worklist environment using
|
||||
* a given study and new tab title.
|
||||
*
|
||||
* @param studyInstanceUid The UID of the Study to be opened
|
||||
* @param title The title to be used for the tab heading
|
||||
*/
|
||||
openNewTab = function(studyInstanceUid, title) {
|
||||
// Generate a unique ID to represent this tab
|
||||
// We can't just use the Mongo entry ID because
|
||||
// then it will change after hot-reloading.
|
||||
var contentid = uuid.new();
|
||||
|
||||
// Create a new entry in the WorklistTabs Collection
|
||||
WorklistTabs.insert({
|
||||
title: title,
|
||||
contentid: contentid,
|
||||
studyInstanceUid: studyInstanceUid,
|
||||
active: false
|
||||
});
|
||||
|
||||
// Update the ViewerData global object
|
||||
ViewerData[contentid] = {
|
||||
title: title,
|
||||
contentid: contentid,
|
||||
studyInstanceUids: [studyInstanceUid]
|
||||
};
|
||||
|
||||
// Switch to the new tab
|
||||
switchToTab(contentid);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Switches to a new tab in the tabbed worklist container
|
||||
* This function renders either the Worklist or the Viewer template with new data.
|
||||
*
|
||||
* @param contentId The unique ID of the tab to be switched to
|
||||
*/
|
||||
switchToTab = function(contentId) {
|
||||
if (!contentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Switching to tab: ' + contentId);
|
||||
|
||||
// Use Bootstrap's Tab JavaScript to show the contents of the current tab
|
||||
// Unless it is the worklist, it is currently an empty div
|
||||
$('.tabTitle a[data-target="#' + contentId + '"]').tab('show');
|
||||
|
||||
// Remove any previous Viewers from the DOM
|
||||
$('.viewerContainer').remove();
|
||||
$('.worklistContainer').remove();
|
||||
|
||||
// Update the 'activeContentId' variable in Session
|
||||
Session.set('activeContentId', contentId);
|
||||
|
||||
// If we are switching to the Worklist tab, reset any CSS styles
|
||||
// that have been applied to prevent scrolling in the Viewer.
|
||||
// Then stop here, since nothing needs to be re-rendered.
|
||||
var container;
|
||||
if (contentId === 'worklistTab') {
|
||||
container = $('.tab-content').find('#worklistTab').get(0);
|
||||
var worklistContainer = document.createElement('div');
|
||||
worklistContainer.classList.add('worklistContainer');
|
||||
container.appendChild(worklistContainer);
|
||||
|
||||
// Use Blaze to render the WorklistResult Template into the container
|
||||
Blaze.render(Template.worklistResult, worklistContainer);
|
||||
|
||||
document.body.style.overflow = null;
|
||||
document.body.style.height = null;
|
||||
document.body.style.minWidth = null;
|
||||
document.body.style.position = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab was closed at some point, stop here
|
||||
if (!ViewerData[contentId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the stored ViewerData global object to retrieve the studyInstanceUid
|
||||
// related to this tab
|
||||
var studyInstanceUids = ViewerData[contentId].studyInstanceUids;
|
||||
|
||||
// Attempt to retrieve the meta data (it might be cached)
|
||||
getStudiesMetadata(studyInstanceUids, function(studies) {
|
||||
// Tab closed while study data was being retrieved, stop here
|
||||
if (!ViewerData[contentId]) {
|
||||
log.warn('Tab closed while study data was being retrieved');
|
||||
return;
|
||||
}
|
||||
|
||||
// Once we have the study data, store it in a structure with
|
||||
// any other saved data about this tab (e.g. layout structure)
|
||||
var data = jQuery.extend({}, ViewerData[contentId]);
|
||||
data.studies = studies;
|
||||
data.contentId = contentId;
|
||||
|
||||
if (ViewerData[contentId].studies && ViewerData[contentId].studies.length) {
|
||||
data.studies = ViewerData[contentId].studies;
|
||||
}
|
||||
|
||||
|
||||
// Add additional metadata to our study from the worklist
|
||||
data.studies.forEach(function(study) {
|
||||
var worklistStudy = WorklistStudies.findOne({
|
||||
studyInstanceUid: study.studyInstanceUid
|
||||
});
|
||||
|
||||
if (!worklistStudy) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.extend(study, worklistStudy);
|
||||
});
|
||||
|
||||
// Get tab content container given the contentId string
|
||||
// If no such container exists, stop here because something is wrong
|
||||
var container = $('.tab-content').find('#' + contentId).get(0);
|
||||
if (!container) {
|
||||
log.warn('No container present with the contentId: ' + contentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the loading text template that is inside the tab container by default
|
||||
var viewerContainer = document.createElement('div');
|
||||
viewerContainer.classList.add('viewerContainer');
|
||||
container.innerHTML = '';
|
||||
container.appendChild(viewerContainer);
|
||||
|
||||
// Use Blaze to render the Viewer Template into the container
|
||||
Blaze.renderWithData(Template.viewer, data, viewerContainer);
|
||||
|
||||
// Retrieve the DOM element of the viewer
|
||||
var imageViewer = $('#viewer');
|
||||
|
||||
// If it is present in the DOM (it should be), then apply
|
||||
// styles to prevent page scrolling and overscrolling on mobile devices
|
||||
if (imageViewer) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.height = '100%';
|
||||
document.body.style.width = '100%';
|
||||
document.body.style.minWidth = 0;
|
||||
|
||||
// Prevent overscroll on mobile devices
|
||||
document.body.style.position = 'fixed';
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
Worklist = {
|
||||
functions: {},
|
||||
callbacks: {}
|
||||
};
|
||||
|
||||
Worklist.callbacks.dblClickOnStudy = dblClickOnStudy;
|
||||
Worklist.callbacks.middleClickOnStudy = dblClickOnStudy;
|
||||
|
||||
function dblClickOnStudy(data) {
|
||||
// Use the formatPN template helper to clean up the patient name
|
||||
var title = formatPN(data.patientName);
|
||||
openNewTab(data.studyInstanceUid, title);
|
||||
}
|
||||
Reference in new issue
Block a user