Updates for Timepoint assocation (LT-60) and Conformance Checks (LT-92, LT-93, LT-94)

This commit is contained in:
Erik Ziegler committed 2016-02-08 19:59:39 +01:00
1 parent 6c4eae3699
commit beaa56c0b3
144 files changed
+5050 -2416

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -1,77 +0,0 @@
cornerstoneWADORSImageLoader = {
internal : {
nextIndex: 0,
imageIds : []
}
};
cornerstoneWADORSImageLoader.addImage = function(image) {
var index = cornerstoneWADORSImageLoader.internal.nextIndex++;
cornerstoneWADORSImageLoader.internal.imageIds[index] = image;
var imageId = 'wadors:' + index;
return imageId;
};
function getMinMax(storedPixelData)
{
// we always calculate the min max values since they are not always
// present in DICOM and we don't want to trust them anyway as cornerstone
// depends on us providing reliable values for these
var min = 65535;
var max = -32768;
var numPixels = storedPixelData.length;
var pixelData = storedPixelData;
for(var index = 0; index < numPixels; index++) {
var spv = pixelData[index];
// TODO: test to see if it is faster to use conditional here rather than calling min/max functions
min = Math.min(min, spv);
max = Math.max(max, spv);
}
return {
min: min,
max: max
};
}
cornerstone.registerImageLoader('wadors', function(imageId) {
var index = imageId.substring(7);
var image = cornerstoneWADORSImageLoader.internal.imageIds[index];
var deferred = $.Deferred();
var mediaType;// = 'image/dicom+jp2';
DICOMWeb.getImageFrame(image.uri, mediaType).then(function(result) {
//console.log(result);
// TODO: add support for retrieving compressed pixel data
var storedPixelData;
if(image.instance.bitsAllocated === 16) {
if(image.instance.pixelRepresentation === 0) {
storedPixelData = new Uint16Array(result.arrayBuffer, result.offset, result.length / 2);
} else {
storedPixelData = new Int16Array(result.arrayBuffer, result.offset, result.length / 2);
}
} else if(image.instance.bitsAllocated === 8) {
storedPixelData = new Uint8Array(result.arrayBuffer, result.offset, result.length);
}
// TODO: handle various color space conversions
var minMax = getMinMax(storedPixelData);
image.imageId = imageId;
image.minPixelValue = minMax.min;
image.maxPixelValue = minMax.max;
image.render = cornerstone.renderGrayscaleImage;
image.getPixelData = function() {
return storedPixelData;
};
//console.log(image);
deferred.resolve(image);
}).catch(function(reason) {
deferred.reject(reason);
});
return deferred;
});
+6 -4
View File
@@ -1,4 +1,4 @@
/*! dicom-parser - v1.2.0 - 2015-11-02 | (c) 2014 Chris Hafey | https://github.com/chafey/dicomParser */
/*! dicom-parser - v1.2.1 - 2016-02-07 | (c) 2014 Chris Hafey | https://github.com/chafey/dicomParser */
(function (root, factory) {
// node.js
@@ -66,6 +66,7 @@ var dicomParser = (function(dicomParser) {
function getDataSetByteStream(transferSyntax, position) {
if(transferSyntax === '1.2.840.10008.1.2.1.99')
{
// https://github.com/nodeca/pako
if(typeof(pako) === "undefined") {
throw 'dicomParser.parseDicom: deflated transfer syntax encountered but pako not loaded';
}
@@ -936,8 +937,9 @@ var dicomParser = (function (dicomParser)
{
throw "dicomParser.ByteStream: missing required parameter 'byteArray'";
}
if((byteArray instanceof Uint8Array) === false) {
throw 'dicomParser.ByteStream: parameter byteArray is not of type Uint8Array';
if((byteArray instanceof Uint8Array) === false &&
(byteArray instanceof Buffer) === false ) {
throw 'dicomParser.ByteStream: parameter byteArray is not of type Uint8Array or Buffer';
}
if(position < 0)
{
@@ -2253,7 +2255,7 @@ var dicomParser = (function (dicomParser)
dicomParser = {};
}
dicomParser.version = "1.2.0";
dicomParser.version = "1.2.1";
return dicomParser;
}(dicomParser));
+34 -19
View File
@@ -1,30 +1,45 @@
Package.describe({
name: "cornerstone",
summary: "Cornerstone Web-based Medical Imaging libraries",
version: '0.0.1'
name: 'cornerstone',
summary: 'Cornerstone Web-based Medical Imaging libraries',
version: '0.0.1'
});
Package.onUse(function (api) {
Package.onUse(function(api) {
api.versionsFrom('1.2.0.2');
api.use('jquery');
api.use('dicomweb');
api.addFiles('client/cornerstone.js', 'client', {bare: true});
api.addFiles('client/cornerstoneMath.js', 'client', {bare: true});
api.addFiles('client/cornerstoneTools.js', 'client', {bare: true});
api.addFiles('client/cornerstoneWADOImageLoader.js', 'client', {bare: true});
api.addFiles('client/cornerstoneWADORSImageLoader.js', 'client', {bare: true});
api.addFiles('client/dicomParser.js', 'client', {bare: true});
api.addFiles('client/hammer.js', 'client', {bare: true});
api.addFiles('client/cornerstone.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneMath.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneTools.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneWADOImageLoader.js', 'client', {
bare: true
});
api.addFiles('client/dicomParser.js', 'client', {
bare: true
});
api.addFiles('client/hammer.js', 'client', {
bare: true
});
api.addFiles('client/measurementManager.js', 'client', {bare: true});
api.addFiles('client/measurementManagerExample.js', 'client', {bare: true});
api.addFiles('client/measurementManager.js', 'client', {
bare: true
});
api.addFiles('client/measurementManagerExample.js', 'client', {
bare: true
});
api.export("cornerstone", 'client');
api.export("cornerstoneMath", 'client');
api.export("cornerstoneTools", 'client');
api.export("cornerstoneWADOImageLoader", 'client');
api.export("cornerstoneWADORSImageLoader", 'client');
api.export("dicomParser", ['client', 'server']);
api.export('cornerstone', 'client');
api.export('cornerstoneMath', 'client');
api.export('cornerstoneTools', 'client');
api.export('cornerstoneWADOImageLoader', 'client');
api.export('dicomParser', ['client', 'server']);
});
@@ -1,52 +0,0 @@
/**
* Converts a String to a UInt8 array of character codes
* @param {String} str Input string
* @returns {Uint8Array} Uint8 Array of character codes
*/
function stringToUint8Array(str) {
var uint = new Uint8Array(str.length);
for (var i = 0,j = str.length;i< j;i++){
uint[i] = str.charCodeAt(i);
}
return uint;
}
function checkToken(token, data, dataOffset) {
if (dataOffset + token.length > data.length) {
//console.log('dataOffset >> ', dataOffset);
return false;
}
for (var i = 0; i < token.length; i++) {
if (token[i] !== data[endIndex++]) {
if (endIndex > 520000) {
console.log('token=',uint8ArrayToString(token));
console.log('data=', uint8ArrayToString(data, dataOffset, endIndex - dataOffset));
console.log('miss at %d %s dataOffset=%d', i, String.fromCharCode(data[endIndex]), endIndex);
console.log('miss at %d %s dataOffset=%d', i, String.fromCharCode(token[endIndex]), endIndex);
}
return false;
}
}
return true;
}
findIndexOfString = function(data, str, offset) {
offset = offset || 0;
var token = stringToUint8Array(str);
for (var i = offset; i < data.length; i++) {
if (data[i] === token[0]) {
//console.log('match @', i);
if (checkToken(token, data, i)) {
return i;
}
}
}
return -1;
};
@@ -1,18 +0,0 @@
/**
* Converts a Uint8 array to a string
* @param data
* @param offset
* @param length
* @returns {string}
*/
uint8ArrayToString = function(data, offset, length) {
offset = offset || 0;
length = length || data.length - offset;
var str = '';
for (var i = offset; i < offset + length; i++) {
str += String.fromCharCode(data[i]);
}
return str;
};
+1 -6
View File
@@ -9,16 +9,11 @@ Package.onUse(function(api) {
// DICOMWeb API functions
api.addFiles('server/namespace.js', 'server');
api.addFiles('server/getImageFrame.js', 'server');
api.addFiles('server/getJSON.js', 'server');
api.addFiles('server/getName.js', 'server');
api.addFiles('server/getNumber.js', 'server');
api.addFiles('server/getString.js', 'server');
// Helper functions
api.addFiles('lib/findIndexOfString.js', 'server');
api.addFiles('lib/uint8ArrayToString.js', 'server');
api.export('DICOMWeb', 'server');
api.export('DICOMWeb', ['client', 'server']);
});
-74
View File
@@ -1,74 +0,0 @@
function findBoundary(header) {
for (var i = 0; i < header.length; i++) {
if (header[i].substr(0,2) === '--') {
return header[i];
}
}
return undefined;
}
function findContentType(header) {
for (var i = 0; i < header.length; i++) {
if (header[i].substr(0,13) === 'Content-Type:') {
return header[i].substr(13).trim();
}
}
return undefined;
}
DICOMWeb.getImageFrame = function(uri, mediaType) {
mediaType = mediaType || 'application/octet-stream';
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
xhr.open('get', uri, true);
xhr.setRequestHeader('Accept', 'multipart/related;type=' + mediaType);
xhr.onreadystatechange = function(oEvent) {
// TODO: consider sending out progress messages here as we receive the pixel data
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// request succeeded, Parse the multi-part mime response
var imageFrameAsArrayBuffer = xhr.response;
var response = new Uint8Array(xhr.response);
// First look for the multipart mime header
var tokenIndex = findIndexOfString(response, '\n\r\n');
if (tokenIndex === -1) {
reject('invalid response - no multipart mime header');
}
var header = uint8ArrayToString(response, 0, tokenIndex);
// Now find the boundary marker
var split = header.split('\r\n');
var boundary = findBoundary(split);
if (!boundary) {
reject('invalid response - no boundary marker');
}
var offset = tokenIndex + 4; // skip over the \n\r\n
// find the terminal boundary marker
var endIndex = findIndexOfString(response, boundary, offset);
if (endIndex === -1) {
reject('invalid response - terminating boundary not found');
}
// return the info for this pixel data
var length = endIndex - offset - 1;
resolve({
contentType: findContentType(split),
arrayBuffer: imageFrameAsArrayBuffer,
offset: offset,
length: length
});
} else {
// request failed, reject the deferred
reject(xhr.response);
}
}
};
xhr.send();
});
};
+2 -2
View File
@@ -1,8 +1,8 @@
DICOMWeb.getJSON = function(url, options) {
var getOptions = {
headers: {
Accept: 'application/json'
},
Accept: 'application/json'
}
};
if (options.auth) {
@@ -1,6 +0,0 @@
// The list of the known DICOM modalities
// If you are running Orthanc with Docker, you need to put the contents of this file in
// /etc/orthanc/orthanc.json
"DicomModalities" : {
"OHIFDCM" : [ "OHIFDCM", "localhost", 3000 ]
},
@@ -0,0 +1,5 @@
// The list of the known DICOM modalities
// If you are running Orthanc , you need to put the contents of this file in its Configuration file
"DicomModalities" : {
"OHIFDCM" : [ "OHIFDCM", "localhost", 3000 ]
},
File renamed without changes.
@@ -8,27 +8,38 @@ DIMSE.associate = function(contexts, callback) {
port = Meteor.settings.dimse.port,
ae = Meteor.settings.dimse.hostAE;
var client = net.connect({
host: host,
port: port
},
function() { //'connect' listener
console.log('==Connected');
console.log("Associating via DIMSE");
console.log(Meteor.settings.dimse);
var conn = new Connection(client, {
vr: {
split: false
}
});
conn.associate({
contexts: contexts,
hostAE: ae
}, function(pdu) {
// associated
console.log('==Associated');
callback.call(conn, pdu);
});
var client = net.connect({
host: host,
port: port
});
client.on('connect', function() {
//'connect' listener
console.log('==Connected');
var conn = new Connection(client, {
vr: {
split: false
}
});
conn.associate({
contexts: contexts,
hostAE: ae
}, function(pdu) {
// associated
console.log('==Associated');
callback.call(conn, pdu);
});
});
client.on('error', function(error) {
throw error;
});
};
DIMSE.retrievePatients = function(params) {
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,2 +1,5 @@
Timepoints = new Meteor.Collection('timepoints');
Studies = new Meteor.Collection('studies');
Measurements = new Meteor.Collection('measurements');
WorklistSubscriptions = ['studies', 'timepoints'];
@@ -6,7 +6,8 @@ LesionLocations.insert({
location: 'Liver Left',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -15,7 +16,8 @@ LesionLocations.insert({
location: 'Liver Right',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -24,7 +26,8 @@ LesionLocations.insert({
location: 'Liver Caudate',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -33,7 +36,8 @@ LesionLocations.insert({
location: 'Lung LLL',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -42,7 +46,8 @@ LesionLocations.insert({
location: 'Lung LUL',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -51,7 +56,8 @@ LesionLocations.insert({
location: 'Lung RLL',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -60,7 +66,8 @@ LesionLocations.insert({
location: 'Lung RML',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -69,7 +76,8 @@ LesionLocations.insert({
location: 'Lung RUL',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -78,7 +86,8 @@ LesionLocations.insert({
location: 'Pleura Left',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -87,7 +96,8 @@ LesionLocations.insert({
location: 'Pleura Right',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -96,7 +106,8 @@ LesionLocations.insert({
location: 'Kidney Left',
hasDescription: false,
description: '',
selected: false
selected: false,
isNodal: false
});
LesionLocations.insert({
@@ -104,5 +115,7 @@ LesionLocations.insert({
group: 'kidney',
location: 'Kidney Right',
hasDescription: false,
description: ''
description: '',
selected: false,
isNodal: false
});
@@ -0,0 +1 @@
PatientLocations = new Meteor.Collection(null);
@@ -1,190 +0,0 @@
var LesionManager = (function() {
PatientLocations = new Meteor.Collection(null);
/**
* Retrieve a location name (e.g. Liver Right) from the
* PatientLocations Collection by id, if it exists. Otherwise,
* return an empty string.
*
* @param id
* @returns {*|string}
*/
function getLocationName(id) {
var locationObject = PatientLocations.findOne(id);
if (!locationObject || !locationObject.location) {
return '';
}
return locationObject.location;
}
/**
* Update the Timepoint object for a specific Measurement.
* If no measurement exists yet, one will be created.
*
* Input is toolData from the lesion or nonTarget tool
*
* @param lesionData
*/
function updateLesionData(lesionData) {
// Find the related Timepoint from the Timepoints Collection
var timepointID = lesionData.timepointID;
var timepoint = Timepoints.findOne({
timepointID: timepointID
});
if (!timepoint) {
log.warn('Timepoint in an image is not present in the Timepoints Collection?');
return;
}
// Find the specific lesion to be updated
var existingMeasurement;
if (lesionData.id && lesionData.id !== 'notready') {
existingMeasurement = Measurements.findOne(lesionData.id);
} else {
existingMeasurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
}
// Create a structure for the timepointData based
// on this Lesion's toolData
var timepointData = {
seriesInstanceUid: lesionData.seriesInstanceUid,
studyInstanceUid: lesionData.studyInstanceUid,
handles: lesionData.handles,
imageId: lesionData.imageId
};
if (lesionData.isTarget === true) {
timepointData.shortestDiameter = lesionData.widthMeasurement;
timepointData.longestDiameter = lesionData.measurementText;
} else {
timepointData.response = lesionData.response;
}
// If no such lesion exists, we need to add one
if (!existingMeasurement) {
// Create a data structure for the Measurement
// based on the current tool data
var measurement = {
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget,
patientId: lesionData.patientId,
id: lesionData.id
};
// Retrieve the location name given the locationUID
if (lesionData.locationUID !== undefined) {
measurement.location = getLocationName(lesionData.locationUID);
}
// Add toolData parameters to the Measurement at this Timepoint
measurement.timepoints = {};
measurement.timepoints[timepointID] = timepointData;
// Set a flag to prevent duplication of toolData
measurement.toolDataInsertedManually = true;
// Increment and store the absolute Lesion Number for this Measurement
measurement.lesionNumberAbsolute = Measurements.find().count() + 1;
// Insert this into the Measurements Collection
// Save the ID into the toolData (not sure if this works?)
measurement.id = Measurements.insert(measurement);
// Update the database entry so it can be readded next time the study is loaded
Measurements.update(measurement.id, {
$set: {
toolDataInsertedManually: false
}
});
} else {
lesionData.id = existingMeasurement._id;
// Update timepoints from lesion data
existingMeasurement.timepoints[timepointID] = timepointData;
Measurements.update(existingMeasurement._id, {
$set: {
timepoints: existingMeasurement.timepoints
}
});
}
}
/**
* Returns new lesion number according to timepointID
* @param timepointID
* @param isTarget
* @returns {*}
*/
function getNewLesionNumber(timepointID, isTarget) {
// Get all current lesion measurements
var numMeasurements = Measurements.find({
isTarget: isTarget
}).count();
// If no measurements exist yet, start at 1
if (!numMeasurements) {
return 1;
}
// Find related measurements (i.e. target or non-target)
var measurements = Measurements.find({
isTarget: isTarget
}, {
sort: {
lesionNumber: 1
}
}).fetch();
// If measurements exist, find the last lesion number
// from the given timepoint
var lesionNumberCounter = 1;
// Search through every Measurement to see which ones
// already have data for this Timepoint, if we find one that
// doesn't have data, we will stop there and use that as the
// current Measurement
measurements.every(function(measurement) {
// If this measurement has no data for this Timepoint,
// use this as the current Measurement
if (!measurement.timepoints[timepointID]) {
lesionNumberCounter = measurement.lesionNumber;
return false;
}
lesionNumberCounter++;
return true;
});
return lesionNumberCounter;
}
/**
* If the current Lesion Number already exists
* for any other timepoint, returns lesion locationUID
* @param lesionData
* @returns {*}
*/
function lesionNumberExists(lesionData) {
var measurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
if (!measurement) {
return;
}
return measurement.locationUID;
}
return {
updateLesionData: updateLesionData,
getNewLesionNumber: getNewLesionNumber,
lesionNumberExists: lesionNumberExists,
getLocationName: getLocationName
};
})();
@@ -50,6 +50,11 @@
};
var config = cornerstoneTools.lesion.getConfiguration();
// Set lesion number and lesion name
if (measurementData.lesionNumber === undefined) {
config.setLesionNumberCallback(measurementData, mouseEventData, doneCallback);
}
// associate this data with this imageId so we can render it and manipulate it
cornerstoneTools.addToolState(element, toolType, measurementData);
@@ -76,11 +81,6 @@
// Bind a one-time event listener for the Esc key
$(element).one('keydown', cancelCallback);
// Set lesion number and lesion name
if (measurementData.lesionNumber === undefined) {
config.setLesionNumberCallback(measurementData, mouseEventData, doneCallback);
}
cornerstone.updateImage(element);
cornerstoneTools.moveNewHandle(mouseEventData, toolType, measurementData, measurementData.handles.end, function() {
@@ -217,8 +217,7 @@
widthMeasurement: 0,
perpendicularMeasurement: 0,
isDeleted: false,
isTarget: true,
uid: uuid.v4()
isTarget: true
};
return measurementData;
}
@@ -51,6 +51,13 @@
mouseButtonMask: mouseEventData.which
};
var config = cornerstoneTools.nonTarget.getConfiguration();
// Set lesion number and lesion name
if (measurementData.lesionNumber === undefined) {
config.setLesionNumberCallback(measurementData, mouseEventData, doneCallback);
}
// associate this data with this imageId so we can render it and manipulate it
cornerstoneTools.addToolState(mouseEventData.element, toolType, measurementData);
@@ -77,13 +84,6 @@
// Bind a one-time event listener for the Esc key
$(element).one('keydown', cancelCallback);
var config = cornerstoneTools.nonTarget.getConfiguration();
// Set lesion number and lesion name
if (measurementData.lesionNumber === undefined) {
config.setLesionNumberCallback(measurementData, mouseEventData, doneCallback);
}
cornerstone.updateImage(element);
cornerstoneTools.moveNewHandle(mouseEventData, toolType, measurementData, measurementData.handles.end, function() {
@@ -101,6 +101,8 @@
$(element).on('CornerstoneToolsMouseDown', eventData, cornerstoneTools.nonTarget.mouseDownCallback);
$(element).on('CornerstoneToolsMouseDownActivate', eventData, cornerstoneTools.nonTarget.mouseDownActivateCallback);
$(element).off('keydown', cancelCallback);
cornerstone.updateImage(mouseEventData.element);
});
}
@@ -50,11 +50,10 @@
x: config.horizontalLine.start.x + i * config.horizontalMinorTick,
y: 0
};
if (i% 5 === 0) {
if (i% 5 === 0) {
endPoint.y = config.horizontalLine.start.y - config.majorTickLength;
} else {
endPoint.y = config.horizontalLine.start.y - config.minorTickLength;
}
File diff suppressed because it is too large. Load diff
@@ -10,8 +10,8 @@
{{ >studyAssociationTable }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="saveAssociations" data-dismiss="modal" data-toggle="modal" data-target="#associationModal">Save</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveAssociations">Save</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal" id='cancelAssociation'>Cancel</button>
</div>
</div>
</div>
@@ -1,5 +1,116 @@
Template.associationModal.events({
'click #saveAssociations': function(e) {
log.info("Saving associations");
log.info('Saving associations');
// Close the modal
var saveButton = $(e.currentTarget);
saveButton.attr('disabled', true);
saveButton.addClass('btn-success').removeClass('btn-primary');
// Find the rows of the study association table
var tableRows = $('#studyAssociationTable table tbody tr');
// Create an empty object to group studies into
var studies = {};
// Loop through each row to parse the data
tableRows.each(function() {
// Get a selector for this row
var row = $(this);
// Check the includeStudy checkbox to see if we should parse this row
var includeStudy = row.find('input.includeStudy[type="checkbox"]').eq(0).prop('checked');
if (!includeStudy) {
return;
}
// Find the selected timepoint option for this study
var timepointInput = row.find('input.timepointOption[type="radio"]:checked');
// Find the related label and trim it down to actual label (TODO: do this another way)
var timepointType = timepointInput.val();
// Get the study metaData by checking the row with the template engine Blaze
var data = Blaze.getData(this);
// Concatenate the study data to an array, depending on whether is was marked as baseline
// or follow-up
if (!studies.hasOwnProperty(timepointType)) {
studies[timepointType] = [];
}
studies[timepointType].push(data);
});
Object.keys(studies).forEach(function(timepointType) {
// Get the studies associated with this timepoint
var relatedStudies = studies[timepointType];
// Create an array of all the studyInstanceUids for storage in the Timepoint
var studyInstanceUids = relatedStudies.map(function(study) {
return study.studyInstanceUid;
});
// Create an array of all the studyDates for storage in the Timepoint
var studyDates = relatedStudies.map(function(study) {
return moment(study.studyDate, 'YYYYMMDD');
});
// Sort the study dates, so we can get a range for these values
studyDates = studyDates.sort();
// Create a new timepoint to represent the (baseline or follow-up) studies
var timepoint = {
timepointType: timepointType,
timepointId: uuid.new(),
studyInstanceUids: studyInstanceUids,
patientId: relatedStudies[0].patientId, // TODO: Revisit this (Should timepoints be related to patientId?)
earliestDate: studyDates[0].format('YYYYMMDD'),
latestDate: studyDates[studyDates.length - 1].format('YYYYMMDD')
};
// Insert this timepoint into the Timepoints Collection
Timepoints.insert(timepoint);
// Loop through these studies to associate them with the newly created timepoint
relatedStudies.forEach(function(study) {
// Check if a study already exists in the Studies collection
var existingStudy = Studies.findOne({
studyInstanceUid: study.studyInstanceUid
});
if (existingStudy) {
// If a study already exists, update the entry with the new timepointId
Studies.update(existingStudy._id, {
$set: {
timepointId: timepoint.timepointId
}
});
} else {
// If no such study exists, update the entry with the new timepointId
// Clear the ID from the document
delete study._id;
// Attach the timepointId and insert it into the Studies Collection
study.timepointId = timepoint.timepointId;
Studies.insert(study);
}
});
});
// Hide the modal
$('#associationModal').modal('hide');
// Reset the save button to its normal state
saveButton.removeClass('btn-success').addClass('btn-primary');
saveButton.attr('disabled', false);
},
'click #cancelAssociation': function() {
// When the modal is closed, we should reset
// the save button to its normal state
var saveButton = $('#saveAssociations');
saveButton.removeClass('btn-success').addClass('btn-primary');
saveButton.attr('disabled', false);
}
});
});
@@ -0,0 +1,7 @@
<template name="conformanceCheckFeedback">
<div id="conformanceCheckFeedback">
{{ #each validationErrors }}
<p data-toggle=""><i class="fa fa-exclamation-triangle fa-lg"></i> {{prefix}}{{error}}</p>
{{ /each }}
</div>
</template>
@@ -0,0 +1,11 @@
Template.conformanceCheckFeedback.helpers({
validationErrors: function() {
// Return validation errors sorted by last added Target
return ValidationErrors.find({}, {
sort: {
prefix: -1,
type: 1
}
});
}
});
@@ -0,0 +1,19 @@
#conformanceCheckFeedback
position: absolute
right: 10px
color: darkorange
cursor: pointer
background: black
width: 30%
height: 30px
overflow: hidden
p
margin: 5px
text-align: center
&:hover
overflow: auto
height: 200px
z-index: 200
@@ -11,24 +11,29 @@ function closeHandler(dialog) {
// This event sets lesion number for new lesion
function setLesionNumberCallback(measurementData, eventData, doneCallback) {
// Get the current element's timepointID from the study date metadata
// Get the current element's timepointId from the study date metadata
var element = eventData.element;
var enabledElement = cornerstone.getEnabledElement(element);
var imageId = enabledElement.image.imageId;
var study = cornerstoneTools.metaData.get('study', imageId);
// Find the relevant timepoint given the current study
var timepoint = Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
if (!timepoint) {
return;
}
measurementData.timepointID = timepoint.timepointID;
measurementData.timepointId = timepoint.timepointId;
// Get a lesion number for this lesion, depending on whether or not the same lesion previously
// exists at a different timepoint
var lesionNumber = LesionManager.getNewLesionNumber(measurementData.timepointID, isTarget = true);
var lesionNumber = LesionManager.getNewLesionNumber(measurementData.timepointId, isTarget = true);
measurementData.lesionNumber = lesionNumber;
// Set lesion number
@@ -226,6 +231,9 @@ Template.lesionLocationDialog.events({
/// Set the isTarget value to true, since this is the target-lesion dialog callback
measurementData.isTarget = true;
// Set the isNodal value based on the Location's properties
measurementData.isNodal = locationObj.isNodal;
// Adds lesion data to timepoints array
LesionManager.updateLesionData(measurementData);
} else {
@@ -233,6 +241,7 @@ Template.lesionLocationDialog.events({
$set: {
location: locationObj.location,
locationId: locationObj.id,
isNodal: locationObj.isNodal,
locationUID: id
}
});
@@ -5,18 +5,18 @@ Template.lesionTableTimepointCell.helpers({
var lesionData = Template.parentData(1);
return (lesionData &&
lesionData.timepoints &&
lesionData.timepoints[this.timepointID]);
lesionData.timepoints[this.timepointId]);
},
displayData: function() {
// Search Measurements by lesion and timepoint
var lesionData = Template.parentData(1);
if (!lesionData ||
!lesionData.timepoints ||
!lesionData.timepoints[this.timepointID]) {
!lesionData.timepoints[this.timepointId]) {
return;
}
var data = lesionData.timepoints[this.timepointID];
var data = lesionData.timepoints[this.timepointId];
if (lesionData.isTarget === true) {
if (data.shortestDiameter) {
@@ -40,7 +40,7 @@ function doneCallback(measurementData, deleteTool) {
// the specified Timepoint Cell
if (deleteTool === true) {
log.info('Confirm clicked!');
clearMeasurementTimepointData(measurementData.id, measurementData.timepointID);
clearMeasurementTimepointData(measurementData.id, measurementData.timepointId);
}
}
@@ -57,12 +57,12 @@ Template.lesionTableTimepointCell.events({
var currentMeasurement = Template.parentData(1);
// Create some fake measurement data
var currentTimepointID = this.timepointID;
var currentTimepointID = this.timepointId;
var timepointData = currentMeasurement.timepoints[currentTimepointID];
var measurementData = {
id: currentMeasurement._id,
timepointID: currentTimepointID,
timepointId: currentTimepointID,
response: timepointData.response,
imageId: timepointData.imageId,
handles: timepointData.handles,
@@ -83,7 +83,7 @@ Template.lesionTableTimepointCell.events({
if (keyCode === keys.DELETE ||
(keyCode === keys.D && e.ctrlKey === true)) {
var currentMeasurement = Template.parentData(1);
var currentTimepointID = this.timepointID;
var currentTimepointID = this.timepointId;
showConfirmDialog(function() {
clearMeasurementTimepointData(currentMeasurement._id, currentTimepointID);
@@ -1,9 +1,6 @@
<template name="lesionTableTimepointHeader">
<th class="lesionTableTimepointCell">
{{formatDA timepointName "MM/DD/YYYY"}}
{{#if timepointTextFound}}
{{timepointText}}
{{/if}}
{{#if timepointLoaded }}<span class="timepointIndicator">&lt;</span> {{/if}}
<th class="lesionTableTimepointCell" title="Latest study date: {{formatDA latestDate "MM/DD/YYYY"}}"
data-toggle="tooltip" data-placement="top">
{{ timepointName }}
</th>
</template>
@@ -1,105 +1,6 @@
Template.lesionTableTimepointHeader.events({
'click th.lesionTableTimepointCell': function(e, template) {
var parentPosition = getPosition(e.currentTarget);
var cellText = e.currentTarget.innerText;
// Remove spaces in string
cellText = cellText.replace(/\s/g, ''); // Remove spaces
cellText = cellText.replace('<', ''); // Remove <
var splitCellText = cellText.split('/');
var dateStr = splitCellText[2].replace(/\D/g,'') + '' + splitCellText[0].replace(/\D/g,'') + '' + splitCellText[1].replace(/\D/g,''); // Remove non-digit chars
// Check patient has a timepointText as Baseline
var patientId = template.data.patientId;
// Open popup
var timepointTextDialog = $('#timepointTextDialog');
var dialogDisplay = timepointTextDialog.css('display');
if (dialogDisplay === 'none') {
var isBaselineInCollection = Timepoints.findOne({
patientId: patientId,
timepointName: dateStr,
timepointText: 'Baseline'
});
// If isBaselineInCollection is true, "Baseline" is found in collection for patient and set checkbox as checked
if (isBaselineInCollection) {
// Set checkbox as checked
$('#checkBoxBaseline').prop('checked', true);
} else {
// Set checkbox as unchecked
$('#checkBoxBaseline').prop('checked', false);
}
// Open dialog
var dialogProperty = {
top: parentPosition.y - 30,
left: parentPosition.x,
display: 'block'
};
timepointTextDialog.css(dialogProperty);
} else {
// Get timepoints of patient
// Set timepointText as Baseline for selected timepoint
var timepoints = Timepoints.find({
patientId: patientId
}).fetch();
// Check checkbox is selected
var checkboxBaselineChecked = $('#checkBoxBaseline').is(':checked');
timepoints.forEach(function(timepoint) {
// timepointText defines a custom text for timepoint such as Baseline, Nadir, Current
if (timepoint.timepointName === dateStr) {
// If checkbox is selected, set timepointText as Baseline
// Else set timepointText as ""
var timepointText = 'Baseline';
if (!checkboxBaselineChecked) {
timepointText = '';
}
Timepoints.update(timepoint._id,{
$set: {
timepointText: timepointText
}
});
} else {
// Set timepointText as empty
Timepoints.update(timepoint._id,{
$set: {
timepointText: ''
}
});
}
});
// Close dialog
timepointTextDialog.css('display', 'none');
}
}
});
Template.lesionTableTimepointHeader.helpers({
timepointTextFound: function() {
var timepointText = this.timepointText;
return (timepointText && timepointText === 'Baseline');
'timepointName': function() {
var timepoint = this;
return getTimepointName(timepoint);
}
});
// Gets parent's position of element which mouse pointer is clicked in
function getPosition(element) {
var xPosition = 0;
var yPosition = 0;
while (element) {
xPosition += (element.offsetLeft - element.scrollLeft + element.clientLeft);
yPosition += (element.offsetTop - element.scrollTop + element.clientTop);
element = element.offsetParent;
}
return {
x: xPosition,
y: yPosition
};
}
});
@@ -0,0 +1,10 @@
<template name="lesionTrackerLayout">
<div class="logoContainer">
<a class="navbar-brand" href="http://ohif.org">
<img src="/images/logo.png">
<h4 class="name">Open Health Imaging Foundation</h4>
</a>
</div>
{{ >optionsButton }}
{{ >yield }}
</template>
@@ -0,0 +1,38 @@
.logoContainer
position: absolute;
height: 40px;
top: 0;
right: 0;
color: white;
@media only screen and (min-device-width : 375px) and (max-device-width : 667px) and (orientation : portrait)
width: 15%
.navbar-brand
display: inline-block
padding: 0
margin-right: 5px
height: 100%
width: 100%
img
height: 30px
float: left
margin-right: 10px
margin-top: 5px;
/* Media queries for iPhone6*/
@media only screen and (min-device-width : 375px) and (max-device-width : 667px) and (orientation : portrait)
height: 20px
margin-top: 10px
margin-bottom: 10px
margin-right: 5px
h4.name
float: left
margin: 10px 0;
font-family: "Sanchez"
color: #C1C1C1
@media only screen and (min-device-width : 375px) and (max-device-width : 667px) and (orientation : portrait)
font-size: 0.3em
font-size: 0.3em
width: 50%;
@@ -0,0 +1,26 @@
<template name="lesionTrackerViewportOverlay">
<div class="imageViewerViewportOverlay noselect">
<div class="topleft dicomTag">
<div>{{formatPN patientName}}</div>
<div>{{patientId}}</div>
<div class='timepointName'>{{timepointName}}</div>
</div>
<div class="topright dicomTag">
<div>{{studyDescription}}</div>
<div>{{formatDA studyDate}} {{formatTM studyTime}}</div>
</div>
<div class="bottomright dicomTag">
<div>Zoom: {{formatNumberPrecision zoom 2}}%</div>
<div>{{compression}}</div>
<div>{{wwwc}}</div>
</div>
<div class="bottomleft dicomTag">
<div>Ser: {{seriesNumber}}</div>
<div>Img: {{imageNumber}} ({{imageIndex}}/{{numImages}})</div>
<div>{{frameRate}}</div>
<div>{{imageDimensions}}</div>
<div>{{seriesDescription}}</div>
</div>
{{>imageControls}}
</div>
</template>
@@ -0,0 +1,30 @@
// Use Aldeed's meteor-template-extension package to replace the
// default viewportOverlay template.
// See https://github.com/aldeed/meteor-template-extension
var defaultTemplate = 'viewportOverlay';
Template.lesionTrackerViewportOverlay.replaces(defaultTemplate);
// Add the TimepointName helper to the default template. The
// HTML of this template is replaced with that of lesionTrackerViewportOverlay
Template[defaultTemplate].helpers({
timepointName: function() {
var data = this;
var study = Studies.findOne({
studyInstanceUid: data.studyInstanceUid
});
if (!study) {
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
return;
}
return getTimepointName(timepoint);
}
});
@@ -0,0 +1,3 @@
.imageViewerViewportOverlay
.timepointName
color: #32BFFF
@@ -1,11 +1,10 @@
<template name="studyContextMenu">
<template name="lesionTrackerWorklistContextMenu">
<div id="studyContextMenu" class="studyContextMenu noselect"
oncontextmenu='return false;'
unselectable='on'
onselectstart='return false;'>
<ul>
<li>
<!--<a id="deleteTool">Delete</a>-->
<!-- TODO: Make this dynamically accept options-->
<a id="launchStudyAssociation" type="button"
data-toggle="modal"
@@ -14,16 +13,20 @@
<i class="fa fa-calendar-plus-o fa-lg"></i>
Associate
</a>
<a><span class="fa-stack fa-lg">
<a id="removeTimepointAssociations" type="button"
title="Remove Timepoint Association">
<i class="fa fa-eraser fa-lg"></i> Remove Association
</a>
<a class="disabled"><span class="fa-stack fa-lg">
<i class="fa fa-user fa-stack-1x"></i>
<i class="fa fa-ban fa-stack-2x text-danger"></i>
</span> Anonymize
</a>
<a><i class="fa fa-trash fa-lg"></i> Delete</a>
<a><i class="fa fa-send-o fa-lg"></i> Send</a>
<a><i class="fa fa-exchange fa-lg"></i> Export</a>
<a><i class="fa fa-download fa-lg"></i> Download</a>
<a><i class="fa fa-photo fa-lg"></i> View Series Details</a>
<a class="disabled"><i class="fa fa-trash fa-lg"></i> Delete</a>
<a class="disabled"><i class="fa fa-send-o fa-lg"></i> Send</a>
<a class="disabled"><i class="fa fa-exchange fa-lg"></i> Export</a>
<a class="disabled"><i class="fa fa-download fa-lg"></i> Download</a>
<a class="disabled"><i class="fa fa-photo fa-lg"></i> View Series Details</a>
</li>
</ul>
</div>
@@ -0,0 +1,75 @@
// Use Aldeed's meteor-template-extension package to replace the
// default WorklistStudy template.
// See https://github.com/aldeed/meteor-template-extension
var defaultTemplate = 'studyContextMenu';
Template.lesionTrackerWorklistContextMenu.replaces(defaultTemplate);
Worklist.functions['removeTimepointAssociations'] = removeTimepointAssociations;
/**
* Removes all present study / timepoint associations from the Clinical Trial
*/
function removeTimepointAssociations() {
// Get a Cursor pointing to the selected Studies from the Worklist
var selectedStudies = WorklistSelectedStudies.find({}, {
sort: {
studyDate: 1
}
});
// Loop through the Cursor of Selected Studies
selectedStudies.forEach(function(selectedStudy) {
// Find the selected study in question in the Collection of
// Timepoint/Study associated Studies
var study = Studies.findOne({
studyInstanceUid: selectedStudy.studyInstanceUid
});
// If the studies that were selected are not already associated
// with a Timepoint, stop here
if (!study) {
return;
}
// Update the Studies Collection to remove the link to this Timepoint
Studies.update(study._id, {
unset: {
timepointId: ''
}
});
// Find the Timepoint that was previously referenced
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
// Find the index of the current studyInstanceUid in the array
// of reference studyInstanceUids
var index = timepoint.studyInstanceUids.indexOf(study.studyInstanceUid);
if (index < 0) {
return;
}
// Remove the specified studyInstanceUid from the array of associated studyInstanceUids
timepoint.studyInstanceUids.splice(index, 1);
// Check if there are still one or more Studies associated with this Timepoint
if (timepoint.studyInstanceUids.length) {
// Update the Timepoints Collection with this modified array for the
// studyInstanceUids attribute
Timepoints.update(timepoint._id, {
$set: {
studyInstanceUids: timepoint.studyInstanceUids
}
});
} else {
// If no more Studies are associated with this Timepoint, we should remove it
// from the Timepoints Collection via a server call
Meteor.call('removeTimepoint', timepoint._id, function(error) {
if (error) {
log.warn(error);
}
});
}
});
}
@@ -0,0 +1,31 @@
<template name="lesionTrackerWorklistStudy">
<tr class="worklistStudy noselect">
<td>
{{formatPN patientName}}
</td>
<td>
{{patientId}}
</td>
{{#unless isTouchDevice}}
<td>
{{accessionNumber}}
</td>
{{/unless}}
<td>
{{formatDA studyDate}} {{ #if timepointName }}({{timepointName}}){{ /if }}
</td>
{{#unless isTouchDevice}}
<td>
{{modalities}}
</td>
{{/unless}}
<td>
{{studyDescription}}
</td>
{{#unless isTouchDevice}}
<td>
{{numberOfStudyRelatedInstances}}
</td>
{{/unless}}
</tr>
</template>
@@ -0,0 +1,30 @@
// Use Aldeed's meteor-template-extension package to replace the
// default WorklistStudy template.
// See https://github.com/aldeed/meteor-template-extension
var defaultTemplate = 'worklistStudy';
Template.lesionTrackerWorklistStudy.replaces(defaultTemplate);
// Add the TimepointName helper to the default template. The
// HTML of this template is replaced with that of lesionTrackerWorklistStudy
Template[defaultTemplate].helpers({
timepointName: function() {
var data = this;
var study = Studies.findOne({
studyInstanceUid: data.studyInstanceUid
});
if (!study) {
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
return;
}
return getTimepointName(timepoint);
}
});
@@ -11,24 +11,29 @@ function closeHandler(dialog) {
// This event sets lesion number for new lesion
function setLesionNumberCallback(measurementData, eventData, doneCallback) {
// Get the current element's timepointID from the study date metadata
// Get the current element's timepointId from the study date metadata
var element = eventData.element;
var enabledElement = cornerstone.getEnabledElement(element);
var imageId = enabledElement.image.imageId;
var study = cornerstoneTools.metaData.get('study', imageId);
// Find the relevant timepoint given the current study
var timepoint = Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
if (!timepoint) {
return;
}
measurementData.timepointID = timepoint.timepointID;
measurementData.timepointId = timepoint.timepointId;
// Get a lesion number for this lesion, depending on whether or not the same lesion previously
// exists at a different timepoint
var lesionNumber = LesionManager.getNewLesionNumber(measurementData.timepointID, isTarget = false);
var lesionNumber = LesionManager.getNewLesionNumber(measurementData.timepointId, isTarget = false);
measurementData.lesionNumber = lesionNumber;
// Set lesion number
@@ -211,7 +216,7 @@ changeNonTargetLocationCallback = function(measurementData, eventData, doneCallb
multi: true
});
var response = measurement.timepoints[measurementData.timepointID].response;
var response = measurement.timepoints[measurementData.timepointId].response;
// TODO = Standardize this. Searching by code probably isn't the best, we should use
// some sort of UID
@@ -65,7 +65,7 @@ changeNonTargetResponse = function(measurementData, eventData, doneCallback) {
return;
}
var response = measurement.timepoints[measurementData.timepointID].response;
var response = measurement.timepoints[measurementData.timepointId].response;
// TODO = Standardize this. Searching by code probably isn't the best, we should use
// some sort of UID
@@ -0,0 +1,12 @@
<template name="optionsButton">
<div class="optionsButton">
<a type="button"
class="btn btn-default"
data-toggle="modal"
data-target="#optionsModal"
title="Trial Options">
<i class="fa fa-cog fa-lg"></i>
Trial Options
</a>
</div>
</template>
@@ -0,0 +1,6 @@
// These settings are temporary!
.optionsButton
position: absolute
top: 3px
right: 350px
cursor: pointer
@@ -0,0 +1,83 @@
<template name="optionsModal">
<div class="modal" id="optionsModal" tabindex="-1" role="dialog" aria-labelledby="optionsModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="optionsModalLabel">Clinical Trial Options</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-12">
<h4>Select a Trial Criteria Type</h4>
<label for="recistCriteria" class="trialCriteriaLabel">
<input type="radio" name="trialCriteria" checked="true" id="recistCriteria" value='RECIST' class="trialCriteria"> RECIST 1.1
</label>
<label for="irRCCriteria" class="trialCriteriaLabel">
<input type="radio" name="trialCriteria" id="irRCCriteria" value='irRC' class="trialCriteria"> irRC
</label>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4>Trial Criteria Descriptions</h4>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#RECISTDescription" aria-controls="home" role="tab" data-toggle="tab">RECIST 1.1</a>
</li>
<li role="presentation">
<a href="#irRCDescription" aria-controls="profile" role="tab" data-toggle="tab">irRC</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane fade in active" id="RECISTDescription">
<h5>Baseline Checks</h5>
<ul>
<li>Extranodal lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness by CT and MR</li>
<li>Extranodal lesions must be >= 20 mm on chest x-ray (although x-rays rarely used for clinical trial assessment)</li>
<li>Nodal lesions must be >= 15 mm short axis AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 2 target lesions per organ</li>
<li>Up to a max of 5 target lesions total</li>
<li>Non-targets can only be assessed as 'present'</li>
<li>Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)</li>
</ul>
<!-- Time Point Measurement Total = Sum of long axis measurements for extranodal target lesion + short axis measurements for nodal lesions</li>-->
</div>
<div role="tabpanel" class="tab-pane fade" id="irRCDescription">
<h5>Baseline Checks</h5>
<ul>
<li>Target lesions must be >= 10 X 10 mm AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 5 target lesions per organ</li>
<li>Up to a max of 10 target lesions total</li>
<li>Non-targets can only be assessed as 'present'</li>
<li>Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)</li>
</ul>
<h5>New Lesion Checks for Follow-ups</h5>
<ul>
<li>New target lesions must be >= 5 X 5 mm AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 5 target lesions per organ</li>
<li>Up to a max of 10 target lesions total</li>
</ul>
<!--Time Point Measurement Total = SPD target lesions + SPD new lesions (SPD = sum of product of long axis and short axis diameters)-->
</div>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4>Additional Options</h4>
<a type="button" class="btn btn-danger clearAllStudyTimepointAssociations">
<i class="fa fa-trash"></i> Clear All Study/Timepoint Associations
</a>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
Session.setDefault('TrialResponseAssessmentCriteria', 'RECIST');
Template.optionsModal.events({
/**
* When the trial criteria radio buttons are changed, change the
* trial assessment criteria for the Lesion Tracker
*
* @param e The 'change' event on the selected radio button
*/
'change input.trialCriteria': function(e) {
// Get the Trial Criteria type that the selected radio button represents
var radioButton = $(e.currentTarget);
var criteriaType = radioButton.val();
// Set this as the current Trial Response Assessment Criteria
// TODO: Update when we have more trial-level support
// (Currently this information is stored in Session, later this will change)
Session.set('TrialResponseAssessmentCriteria', criteriaType);
log.info('Trial Criteria changed to: ' + criteriaType);
},
/**
* When the Clear Study/Timepoint Associations button is clicked, we
* send a call to the server to erase all entries in the Timepoints Collection.
*/
'click a.clearAllStudyTimepointAssociations': function() {
Meteor.call('clearAllTimepoints', function(error) {
if (error) {
log.warn(error);
}
});
}
});
@@ -0,0 +1,2 @@
.trialCriteriaLabel
margin: 0 5px
@@ -43,13 +43,15 @@
<td class="studyDataCell {{ #if autoselected }}disabled{{ /if }}">
<p>{{studyDescription}}</p>
</td>
<td class="timepointOptions center studyDataCell {{ #if autoselected }}disabled{{ /if }}">
<td class="timepointOptions center studyDataCell noselect {{ #if autoselected }}disabled{{ /if }}">
{{ #each timepointOptions }}
<label>
<label class="noselect">
<input type="radio"
name="{{../_id}}"
value={{type}}
{{ inlineIf autoselected true 'disabled'}}>
name="{{../_id}}"
value="{{value}}"
class="timepointOption"
{{ inlineIf checked true 'checked' }}
{{ inlineIf autoselected true 'disabled' }}>
{{name}}
</label>
{{ /each }}
@@ -30,8 +30,13 @@ function getDateRange(selectedStudies, range) {
}
/**
* Selects all studies related to the currently input studies,
* based on various criteria. Returns the entire array of related studies.
*
* @returns {Array}
* (at the moment, this is only the date range +/- 14 days, with a matching patientId)
*
* @param selectedStudies A user-selected list of studies
* @returns {*} The entire array of related studies
*/
function autoSelectStudies(selectedStudies) {
if (!selectedStudies.length) {
@@ -40,6 +45,9 @@ function autoSelectStudies(selectedStudies) {
var range = getDateRange(selectedStudies);
// Fetch autoselected studies based on the date range
// Note that we used MongoDB's fetch here so we have a mutable array,
// rather than a Cursor
var autoselected = WorklistStudies.find({
studyDate: {
$gte: range.earliestDate.format('YYYYMMDD'),
@@ -82,8 +90,8 @@ Template.studyAssociationTable.helpers({
studyDate: 1
}
}).fetch() || [];
var autoselected = autoSelectStudies(selectedStudies);
return autoselected;
return autoSelectStudies(selectedStudies);
},
/**
* This helper returns the list of Timepoint types the user can set for this study
@@ -91,16 +99,15 @@ Template.studyAssociationTable.helpers({
* @returns {Array.<T>}
*/
timepointOptions: function() {
return [
{
return [{
value: 'baseline',
name: 'Baseline'
},
{
name: 'Baseline',
checked: true
}, {
value: 'followup',
name: 'Follow-up'
}
];
name: 'Follow-up',
checked: false
}];
},
earliestDate: function() {
var selectedStudies = WorklistSelectedStudies.find({}, {
@@ -108,12 +115,11 @@ Template.studyAssociationTable.helpers({
studyDate: 1
}
}).fetch();
var range = getDateRange(selectedStudies);
if (!range) {
return;
}
return range.earliestDate;
var range = getDateRange(selectedStudies);
if (range) {
return range.earliestDate;
}
},
latestDate: function() {
var selectedStudies = WorklistSelectedStudies.find({}, {
@@ -121,12 +127,11 @@ Template.studyAssociationTable.helpers({
studyDate: 1
}
}).fetch();
var range = getDateRange(selectedStudies);
if (!range) {
return;
}
return range.latestDate;
var range = getDateRange(selectedStudies);
if (range) {
return range.latestDate;
}
}
});
@@ -143,12 +148,3 @@ Template.studyAssociationTable.events({
}
}
});
//trial criteria!
/*There shall be Associate option in right-click dialog
If associated, double-click shall go to image view.
If not associated, user shall be directed to Associate Time Points Dialog
Use shall also be allowed to select multiple studies from study list to associate
Associate Time Point dialog shall present selected studies and studies within defined time window of =/- 14 days of selected studies
User should only be able to associate one time point at a time (user should not be able to select both BL and F/U for different studies in associate dialog)
*/
@@ -4,7 +4,7 @@
<span class="loading"><i class="fa fa-spinner fa-spin"></i></span>
<select id="selectStudyDate">
{{#each relatedStudies}}
<option value="{{studyInstanceUid}}" selected={{selected}}>{{formatDA studyDate}}</option>
<option value="{{studyInstanceUid}}" selected={{selected}}>{{formatDA studyDate}} ({{timepointName}})</option>
{{/each}}
</select>
</div>
@@ -4,21 +4,22 @@ Template.studyDateList.helpers({
* The value for 'selected' for the currently loaded study is set to true, so that
* this becomes the current option in the combo box.
*
* @returns {*}
* @returns {*} Array of studies that are related to the current study by patient ID
*/
relatedStudies: function() {
// TODO= Fix this! This won't work to retrieve all studies
// related to this patient. We will need to do a real search
// since the WorklistStudies Collection only contains the studies on-screen
// Check which study is currently loaded into the study browser
var currentStudyInBrowser = ViewerStudies.findOne({
selected: true
});
// Find studies which have the same patientId as the currently selected study
var relatedStudies = WorklistStudies.find({patientId: currentStudyInBrowser.patientId},
{sort: {studyDate: 1}}).fetch();
// Find all Timepoint-associated studies which have the same patientId as the currently selected study
var relatedStudies = Studies.find({
patientId: currentStudyInBrowser.patientId
}, {
sort: {
studyDate: 1
}
});
// Modify the array of related studies so the default option is the currently selected study
relatedStudies.forEach(function(study) {
@@ -31,6 +32,26 @@ Template.studyDateList.helpers({
// Use this array to populate the combo box
return relatedStudies;
},
timepointName: function() {
var data = this;
var study = Studies.findOne({
studyInstanceUid: data.studyInstanceUid
});
if (!study) {
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
return;
}
return getTimepointName(timepoint);
}
});
@@ -46,7 +67,6 @@ Template.studyDateList.events({
*/
'change select#selectStudyDate': function(e) {
var selectBox = $(e.currentTarget);
var studyInstanceUid = selectBox.val();
// Hide the select box
@@ -57,6 +77,11 @@ Template.studyDateList.events({
loadingIndicator.css('display', 'block');
Meteor.call('GetStudyMetadata', studyInstanceUid, function(error, study) {
if (error) {
log.warn(error);
return;
}
sortStudy(study);
// Hide the loading indicator
@@ -66,21 +91,20 @@ Template.studyDateList.events({
selectBox.css('display', 'block');
// Set "Selected" to false for the entire collection
ViewerStudies.update({},
{
$set: {
selected: false
}
},
{
multi: true
});
ViewerStudies.update({}, {
$set: {
selected: false
}
}, {
multi: true
});
// Check if this study already exists in the ViewerStudies collection
// of loaded studies. If it does, set it's 'selected' value to true.
var existingStudy = ViewerStudies.findOne({
studyInstanceUid: studyInstanceUid
});
if (existingStudy) {
// Set the current finding in the collection to true
ViewerStudies.update(existingStudy._id, {
@@ -95,29 +119,6 @@ Template.studyDateList.events({
// with the value True, and insert it into the ViewerStudies Collection
study.selected = true;
ViewerStudies.insert(study);
var timepointID = uuid.v4();
var timepoint = Timepoints.findOne({
timepointName: study.studyDate
});
if (timepoint) {
log.warn('A timepoint with that study date already exists!');
return;
}
var testTimepoint = Timepoints.findOne({});
if (testTimepoint && testTimepoint.patientId !== study.patientId) {
log.warn('Timepoints collection related to the wrong subject');
return;
}
log.info('Inserting a new timepoint');
Timepoints.insert({
patientId: study.patientId,
timepointID: timepointID,
timepointName: study.studyDate
});
});
}
});
+202
View File
@@ -0,0 +1,202 @@
/**
* Retrieve a location name (e.g. Liver Right) from the
* PatientLocations Collection by id, if it exists. Otherwise,
* return an empty string.
*
* @param id
* @returns {*|string}
*/
function getLocationName(id) {
var locationObject = PatientLocations.findOne(id);
if (!locationObject || !locationObject.location) {
return '';
}
return locationObject.location;
}
/**
* Update the Timepoint object for a specific Measurement.
* If no measurement exists yet, one will be created.
*
* Input is toolData from the lesion or nonTarget tool
*
* @param lesionData
*/
function updateLesionData(lesionData) {
var study = Studies.findOne({
studyInstanceUid: lesionData.studyInstanceUid
});
if (!study) {
log.warn('Study is not associated with a timepoint');
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
log.warn('Timepoint in an image is not present in the Timepoints Collection?');
return;
}
// Find the specific lesion to be updated
var existingMeasurement;
if (lesionData.id && lesionData.id !== 'notready') {
existingMeasurement = Measurements.findOne(lesionData.id);
} else {
existingMeasurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
}
// Create a structure for the timepointData based
// on this Lesion's toolData
var timepointData = {
seriesInstanceUid: lesionData.seriesInstanceUid,
studyInstanceUid: lesionData.studyInstanceUid,
sopInstanceUid: lesionData.sopInstanceUid,
handles: lesionData.handles,
imageId: lesionData.imageId
};
if (lesionData.isTarget === true) {
timepointData.shortestDiameter = lesionData.widthMeasurement;
timepointData.longestDiameter = lesionData.measurementText;
} else {
timepointData.response = lesionData.response;
}
// If no such lesion exists, we need to add one
if (!existingMeasurement) {
// Create a data structure for the Measurement
// based on the current tool data
var measurement = {
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget,
patientId: lesionData.patientId,
id: lesionData.id
};
// Retrieve the location name given the locationUID
if (lesionData.locationUID !== undefined) {
var locationObj = PatientLocations.findOne({
locationUID: lesionData.locationUID
});
measurement.location = locationObj.location;
measurement.isNodal = locationObj.isNodal;
}
// Add toolData parameters to the Measurement at this Timepoint
measurement.timepoints = {};
measurement.timepoints[timepoint.timepointId] = timepointData;
// Set a flag to prevent duplication of toolData
measurement.toolDataInsertedManually = true;
// Increment and store the absolute Lesion Number for this Measurement
measurement.lesionNumberAbsolute = Measurements.find().count() + 1;
// Insert this into the Measurements Collection
// Save the ID into the toolData (not sure if this works?)
measurement.id = Measurements.insert(measurement);
// Update the database entry so it can be readded next time the study is loaded
Measurements.update(measurement.id, {
$set: {
toolDataInsertedManually: false
}
});
} else {
lesionData.id = existingMeasurement._id;
lesionData.isNodal = existingMeasurement.isNodal;
// Update timepoints from lesion data
existingMeasurement.timepoints[timepoint.timepointId] = timepointData;
Measurements.update(existingMeasurement._id, {
$set: {
timepoints: existingMeasurement.timepoints
}
});
}
}
/**
* Returns new lesion number according to timepointId
* @param timepointId
* @param isTarget
* @returns {*}
*/
function getNewLesionNumber(timepointId, isTarget) {
// Get all current lesion measurements
var numMeasurements = Measurements.find({
isTarget: isTarget
}).count();
// If no measurements exist yet, start at 1
if (!numMeasurements) {
return 1;
}
// Find related measurements (i.e. target or non-target)
var measurements = Measurements.find({
isTarget: isTarget
}, {
sort: {
lesionNumber: 1
}
}).fetch();
// If measurements exist, find the last lesion number
// from the given timepoint
var lesionNumberCounter = 1;
// Search through every Measurement to see which ones
// already have data for this Timepoint, if we find one that
// doesn't have data, we will stop there and use that as the
// current Measurement
measurements.every(function(measurement) {
// If this measurement has no data for this Timepoint,
// use this as the current Measurement
if (!measurement.timepoints[timepointId]) {
lesionNumberCounter = measurement.lesionNumber;
return false;
}
lesionNumberCounter++;
return true;
});
return lesionNumberCounter;
}
/**
* If the current Lesion Number already exists
* for any other timepoint, returns lesion locationUID
* @param lesionData
* @returns {*}
*/
function lesionNumberExists(lesionData) {
var measurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
if (!measurement) {
return;
}
return measurement.locationUID;
}
LesionManager = {
updateLesionData: updateLesionData,
getNewLesionNumber: getNewLesionNumber,
lesionNumberExists: lesionNumberExists,
getLocationName: getLocationName
};
@@ -0,0 +1,388 @@
// Define the Trial Criteria Structure
TrialCriteriaConstraints = {
RECIST: RECIST,
irRC: irRC
};
/**
* RECIST 1.1 Trial Criteria Definition
*
* Baseline Checks:
* - Extranodal lesions must be >/= 10 mm long axis AND >/= double the acquisition slice thickness by CT and MR
* - Extranodal lesions must be >/= 20 mm on chest x-ray (although x-rays rarely used for clinical trial assessment)
* - Nodal lesions must be >/= 15 mm short axis AND >/= double the acquisition slice thickness by CT and MR
* - Up to a max of 2 target lesions per organ
* - Up to a max of 5 target lesions total
* - Non-targets can only be assessed as 'present'
* - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
* - Time Point Measurement Total = Sum of long axis measurements for extranodal target lesion + short axis measurements for nodal lesions
*/
function RECIST(image) {
var acquisitionSliceThickness;
if (image) {
acquisitionSliceThickness = image.acquisitionSliceThickness;
// TODO: Use metaData to determine if this is a chest X-ray
var isChestXray = false;
}
// Define the RECIST 1.1 structure
var criteria = {
baseline: {
target: {},
nonTarget: {},
group: {}
}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.nodal = {
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(15, 2 * acquisitionSliceThickness),
message: '^Nodal lesions must be >= 15 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.baseline.target.nodal = {
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 15,
message: '^Nodal target lesions must be >= %{count} mm short axis'
}
}
};
}
criteria.baseline.target.all = {
// - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
response: {
exclusion: {
within: {
CR: 'Complete Response (CR)',
UN: 'Unknown (UN)',
NE: 'Non-evaluable (NE)',
EX: 'Excluded (EX)'
},
message: '^Target lesions must have a length and cannot be marked as %{value} at baseline.'
}
},
totalLesionBurden: {
numericality: {
greaterThanOrEqualTo: 2, // TODO: Check this, the value wasn't specified!
message: '^Total lesion burden (SPD target lesions + SPD new lesions) should be greater than %{count}.'
}
}
};
criteria.baseline.nonTarget.all = {
// - Non-targets can only be assessed as 'present'
response: {
// This is a workaround since Validating equality to something is not implemented yet
// https://github.com/ansman/validate.js/issues/79
presence: {
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
},
inclusion: {
within: ['Present'],
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
}
}
};
criteria.baseline.perOrgan = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 2,
//message: '^A maximum of %{count} target lesions per organ are allowed at Baseline.'
}
}
};
criteria.baseline.group = {
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 5,
//message: '^A maximum of %{count} target lesions total are allowed at Baseline.'
}
}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.extraNodal = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, 2 * acquisitionSliceThickness),
message: '^Extranodal lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else if (isChestXray) {
criteria.baseline.target.extraNodal = {
// -
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 20,
message: '^Extranodal lesions must be >= %{count} mm on chest X-ray'
}
}
};
} else {
criteria.baseline.target.extraNodal = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Extranodal target lesions must be >= %{count} mm long axis'
}
}
};
}
return criteria;
}
/**
* irRC Trial Criteria Definition
*
* Baseline Checks:
* - Target lesions must be >/= 10 X 10 mm
* - Up to a max of 5 target lesions per organ
* - Up to a max of 10 target lesions total
* - Non-targets can only be assessed as 'present'
* - Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)
*/
function irRC(image) {
var acquisitionSliceThickness;
if (image) {
acquisitionSliceThickness = image.acquisitionSliceThickness;
}
// Define the irRC structure
var criteria = {
baseline: {
target: {},
nonTarget: {}
},
followup: {
newLesions: {
target: {}
},
target: {}
},
all: {}
};
if (acquisitionSliceThickness) {
criteria.baseline.target.all = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, acquisitionSliceThickness),
message: '^Target lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(10, acquisitionSliceThickness),
message: '^Target lesions must be >= 10 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.baseline.target.all = {
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Target lesions must be >= %{count} mm long axis.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 10,
message: '^Target lesions must be >= %{count} mm short axis.'
}
}
};
}
criteria.baseline.target.all.response = {
exclusion: {
within: {
CR: 'Complete Response (CR)',
UN: 'Unknown (UN)',
NE: 'Non-evaluable (NE)',
EX: 'Excluded (EX)'
},
message: '^^Target lesions must have a length and cannot be marked as %{value} at baseline.'
}
};
criteria.baseline.nonTarget.all = {
response: {
// This is a workaround since Validating equality to something is not implemented yet
// https://github.com/ansman/validate.js/issues/79
presence: {
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
},
inclusion: {
within: ['Present'],
message: "^Non-target lesions can only be assessed as 'Present' at Baseline"
}
}
};
criteria.baseline.perOrgan = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 5,
//message: '^A maximum of %{count} target lesions per organ are allowed at Baseline.'
}
}
};
criteria.baseline.group = {
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 10,
//message: '^A maximum of %{count} target lesions total are allowed at Baseline.'
}
}
};
if (acquisitionSliceThickness) {
criteria.followup.newLesions.target.all = {
// - New target lesions must be >/= 5 X 5 mm AND >/= double the acquisition slice thickness by CT and MR
longestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(5, 2 * acquisitionSliceThickness),
message: '^New target lesions must be >= 5 mm long axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: Math.min(5, 2 * acquisitionSliceThickness),
message: '^New target lesions must be >= 5 mm short axis AND >= double the acquisition slice thickness (' +
acquisitionSliceThickness + ' mm) for CT and MR.'
}
}
};
} else {
criteria.followup.newLesions.target.all = {
// - New target lesions must be >/= 5 X 5 mm
longestDiameter: {
numericality: {
greaterThanOrEqualTo: 5,
message: '^New target lesions must be >= %{count} mm long axis.'
}
},
shortestDiameter: {
numericality: {
greaterThanOrEqualTo: 5,
message: '^New target lesions must be >= %{count} mm short axis.'
}
}
};
}
criteria.followup.group = {
numberOfLesionsPerOrgan: {
numericality: {
lessThanOrEqualTo: 5,
message: '^A maximum of %{count} target lesions per organ are allowed at Followup.'
}
},
totalNumberOfLesions: {
numericality: {
lessThanOrEqualTo: 10,
message: '^A maximum of %{count} target lesions total are allowed at Followup.'
}
}
};
// TODO: Check the actual requirement for total burden!
criteria.all.group = {
totalLesionBurden: {
numericality: {
greaterThanOrEqualTo: 100,
message: '^Total lesion burden (SPD target lesions + SPD new lesions) should be greater than %{count}.'
}
}
};
return criteria;
}
/**
* Retrieve trial criteria constraints based on the image that measurements appear upon
* If no image is specified, it is assumed that group or per Organ level criteria are desired.
*
* @param criteriaType A valid Trial Criteria set name (e.g. 'RECIST' or 'irRC')
* @param imageId A Cornerstone Image ID
* @returns {*} An Object of Trial Criteria that can be used to validate measurements' conformance
*/
getTrialCriteriaConstraints = function(criteriaType, imageId) {
if (!TrialCriteriaConstraints[criteriaType]) {
throw 'No such Trial Criteria defined: ' + criteriaType;
}
// If no imageId was specified, skip customization of the criteria
// and return the requested criteria right away
var criteria;
if (!imageId) {
criteria = TrialCriteriaConstraints[criteriaType]();
return criteria;
}
// Otherwise, retrieve the series metaData to identify the modality of the image
var seriesMetaData = cornerstoneTools.metaData.get('series', imageId);
// TODO: Get the rest of the metaData that has already been loaded by Cornerstone
var image = {};
// If we are looking at an MR or CT image, we should pass the slice thickness
// to the Trial Criteria functions so that they can customize the validation rules
if (seriesMetaData.modality === 'MR' || seriesMetaData.modality === 'CT') {
var instanceMetaData = cornerstoneTools.metaData.get('instance', imageId);
image.acquisitionSliceThickness = instanceMetaData.sliceThickness;
}
// Retrieve the study metaData in order to find the timepoint type
var studyMetaData = cornerstoneTools.metaData.get('study', imageId);
if (!studyMetaData) {
return;
}
// Retrieve the Study document from the Collection of associated Studies
var study = Studies.findOne({
studyInstanceUid: studyMetaData.studyInstanceUid
});
if (!study) {
log.warn('No study/timepoint association.');
return;
}
// Find the related Timepoint document
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
log.warn('Timepoint related to study is missing.');
return;
}
// Retrieve the Timepoint's type (e.g. 'baseline' or 'followup')
var timepointType = timepoint.timepointType;
// Obtain the customized trial criteria given the image metaData
criteria = TrialCriteriaConstraints[criteriaType](image);
// Return the relevant criteria given the current timepoint type
return criteria[timepointType];
};
@@ -0,0 +1,343 @@
// Create a client-only Collection to store our Validation Errors
ValidationErrors = new Meteor.Collection(null);
// Set Validate.js Library's default options
validate.options = {
format: 'detailed'
};
/**
* Creates an array of validation error messages given an Object of validation errors
* and an optional prefix for the messages. An example of a useful prefix would be
* the location of the measurement or something like 'Target 1 '.
*
* @param validationErrors
* @param prefix
*/
function addValidationErrorsToCollection(validationErrors, prefix, type) {
// If no input was given, stop here
if (!validationErrors || !validationErrors.length) {
return;
}
// Loop through each of the entries in the validationErrors Array
validationErrors.forEach(function(validationError) {
var existingError = ValidationErrors.findOne({
attribute: validationError.attribute,
validator: validationError.validator,
error: validationError.error,
prefix: prefix
});
if (existingError) {
ValidationErrors.update(existingError._id, {
$set: {
value: validationError.value
}
});
} else {
validationError.type = type;
validationError.prefix = prefix;
ValidationErrors.insert(validationError);
}
});
}
/**
* Runs conformance checks related to a group of measurements. This function
* searches the input object of Constraints and looks for the 'group' attribute.
*
* It calculates some general group-level values for the current set of Measurements
* and validates these using the input constraints.
*
* @param constraints
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessGroupOfMeasurements(constraints) {
// Retrieve the group-level constraints
var groupConstraints = constraints.group;
// If no group-level constraints exist, stop here
if (!groupConstraints) {
return;
}
var type = 'group';
ValidationErrors.remove({
type: type
});
// Get the criteria type so we can calculate total lesion burden
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
// Calculate some simple group-level Measurement statistics for validation
var testStructure = {
totalNumberOfLesions: Measurements.find().count(),
totalLesionBurden: calculateTotalLesionBurden(criteriaType)
};
// Run the conformance checks with the validate.js library
var validationErrors = validate(testStructure, groupConstraints);
// Return any error messages as a flattened array of errors
addValidationErrorsToCollection(validationErrors, '', type);
}
/**
* Runs conformance checks related to per-organ sets of measurements.
*
* This function searches the input object of Constraints and looks for the
* 'perOrgan' attribute.
*
* It calculates some general per-organ statistics for the current set of Measurements
* and validates these using the input constraints.
*
* @param constraints
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessMeasurementPerOrgan(constraints) {
// Retrieve the per-organ constraints
var perOrganConstraints = constraints.perOrgan;
// If no per-organ constraints exist, stop here
if (!perOrganConstraints) {
return;
}
// Create a list of all unique locations that contain measurements
// by looping through the Measurements Collection
var organLocations = [];
Measurements.find().forEach(function(measurement) {
if (organLocations.indexOf(measurement.location) > -1) {
return;
}
organLocations.push(measurement.location);
});
var type = 'perOrgan';
ValidationErrors.remove({
type: type
});
// Loop through each unique organ location in order to validate
// the per-organ constraints for each organ
organLocations.forEach(function(location) {
// Calculate the number of Lesions per Organ
var numberOfLesionsPerOrgan = Measurements.find({
location: location
}).count();
// Store per-organ Measurement statistics for validation
// Right now this is only the numberOfLesionsPerOrgan, but later
// this may include other checks
var testStructure = {
numberOfLesionsPerOrgan: numberOfLesionsPerOrgan
};
// Run the conformance checks with the validate.js library
var validationErrors = validate(testStructure, perOrganConstraints);
// Obtain any error messages as a flattened array of errors, prefixed
// with the Organ name, in the form 'Liver Left: '
addValidationErrorsToCollection(validationErrors, location + ': ', type);
});
}
/**
* Runs conformance checks on a single Measurement given the
* cornerstone toolData related to it.
*
* @param constraints
* @param measurementData CornerstoneTools toolData Object for this specific Measurement
* @returns {Array} Array of error messages related to the input conformance checks
*/
function assessSingleMeasurement(constraints, measurementData) {
// Check whether this is a Target or Non-Target Measurement
var targetType = measurementData.isTarget ? 'target' : 'nonTarget';
// Retrieve any target/non-target-specific single-measurement constraints
// from the input constraint structure
var measurementConstraints = constraints[targetType];
// If no relevant constraints exist, stop here
if (!measurementConstraints) {
return;
}
// Check whether this is a Nodal or Extranodal Measurement
var nodalType = measurementData.isNodal ? 'nodal' : 'extraNodal';
// Retrieve any nodal/extra-nodal-specific constraints to see if we can apply them
var constraintsToApply;
if (measurementData.isNodal !== undefined && measurementConstraints[nodalType]) {
// Check if we have enough information (about nodality of this Measurement,
// and nodality-specific constraints) to apply nodality-specific constraints
constraintsToApply = measurementConstraints[nodalType];
} else if (measurementConstraints.all) {
// If we have no data about the nodality of this Measurement, or no relevant
// specific constraints, we should apply the constraints valid for 'all' nodality
// types
constraintsToApply = measurementConstraints.all;
}
// Calculate a lesion name based on whether or not we have a Target or Non-target
// Measurement, and the lesion number of this Measurement.
var lesionName = measurementData.isTarget ? 'Target' : 'Non-target';
lesionName = lesionName + ' ' + measurementData.lesionNumber + ': ';
ValidationErrors.remove({
prefix: lesionName
});
// Use validate.js to check the criteria
var validationErrors = validate(measurementData, constraintsToApply);
if (validationErrors) {
validationErrors.forEach(function(error) {
error.measurementId = measurementData._id;
});
}
// Use the Lesion Name as a prefix to concatenate any validation error messages into
// an array to return
addValidationErrorsToCollection(validationErrors, lesionName);
}
/**
* Validate from a single Measurement up the chain to include group and perOrgan
* conformance checks
*
* @param measurementData The CornerstoneTools toolData for a single Measurement
*/
function validateSingleMeasurement(measurementData) {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
var currentConstraints = getTrialCriteriaConstraints(criteriaType, measurementData.imageId);
if (!currentConstraints) {
log.warn('No relevant contraints could be applied');
return;
}
// Find the relevant Measurement in the Measurements Collection
var measurement = Measurements.findOne(measurementData.id);
// If no such Measurement exists, stop here
if (!measurement) {
log.warn('No Measurement found?');
return;
}
// Find the current timepointId that the user was editing the Measurement on
var timepointId = measurementData.timepointId;
// Find the specific measurement data for this Measurement at this Timepoint
var currentMeasurement = measurement.timepoints[timepointId];
// Include target and nodal flags on the timepoint-specific data so it is easier to validate
// TODO: Rethink what to pass to assessSingleMeasurement?
currentMeasurement.isTarget = measurement.isTarget;
currentMeasurement.isNodal = measurement.isNodal;
currentMeasurement.lesionNumber = measurement.lesionNumber;
currentMeasurement._id = measurement._id;
// Run the single-measurement-specific conformance checks
// If any messages exist, add them to the array of messages
assessSingleMeasurement(currentConstraints, currentMeasurement);
validateGroups();
}
function validateGroups() {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
Timepoints.find().forEach(function(timepoint) {
// TODO: Criteria for the specific image are retrieved from the general set of criteria.
// - The acquisitionSliceThickness, for example, may be pulled from the image metadata
// - The organ in question, e.g. Chest X-ray, may determine the exact specifications for the current trial criteria
var currentConstraints = getTrialCriteriaConstraints(criteriaType);
// Retrieve the current constraints which apply to the specific Timepoint type
// (e.g. baseline, followup) that this Measurement is being edited on.
var timepointConstraints = currentConstraints[timepoint.timepointType];
if (!timepointConstraints) {
return;
}
// Run the group-level conformance checks
assessGroupOfMeasurements(timepointConstraints);
// Run the per-organ conformance checks
assessMeasurementPerOrgan(timepointConstraints);
});
}
function validateAll() {
// Obtain the name of the current TrialResponseAssessmentCriteria that
// we are using.
var criteriaType = Session.get('TrialResponseAssessmentCriteria');
Measurements.find().forEach(function(measurement) {
Object.keys(measurement.timepoints).forEach(function(timepointId) {
var currentMeasurement = measurement.timepoints[timepointId];
currentMeasurement.isTarget = measurement.isTarget;
currentMeasurement.isNodal = measurement.isNodal;
currentMeasurement.lesionNumber = measurement.lesionNumber;
currentMeasurement._id = measurement._id;
// TODO: Criteria for the specific image are retrieved from the general set of criteria.
// - The acquisitionSliceThickness, for example, may be pulled from the image metadata
// - The organ in question, e.g. Chest X-ray, may determine the exact specifications for the current trial criteria
var currentConstraints = getTrialCriteriaConstraints(criteriaType, currentMeasurement.imageId);
// Run the single-measurement-specific conformance checks
// If any messages exist, add them to the array of messages
assessSingleMeasurement(currentConstraints, currentMeasurement);
});
});
validateGroups();
}
var validationTimeout = 400;
/**
* Validate the measurements after a set delay period
*
* @param measurementData Input measurement data from CornerstoneTools
*/
function validateDelayed(measurementData) {
// Erase any currently-waiting validation call
clearTimeout(validationTimeout);
// Set a timeout to run validation after a delay
// Currently this is 400 milliseconds
setTimeout(function() {
validateSingleMeasurement(measurementData);
}, validationTimeout);
}
/**
* Validate all measurements after a set delay period
*/
function validateAllDelayed() {
// Erase any currently-waiting validation call
clearTimeout(validationTimeout);
// Set a timeout to run validation after a delay
// Currently this is 400 milliseconds
setTimeout(function() {
validateAll();
}, validationTimeout);
}
TrialResponseCriteria = {
validateAll: validateAll,
validateAllDelayed: validateAllDelayed,
validateSingleMeasurement: validateSingleMeasurement,
validateDelayed: validateDelayed,
validateGroups: validateGroups
};
@@ -12,7 +12,7 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
var timepointData = getTimepointObject(imageId);
var measurementData = Measurements.findOne(measurementId);
var measurementAtTimepoint = measurementData.timepoints[timepointData.timepointID];
var measurementAtTimepoint = measurementData.timepoints[timepointData.timepointId];
if (!measurementAtTimepoint) {
return;
}
@@ -35,11 +35,11 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
}
if (imageIdIndex === elementCurrentImageIdIndex) {
activateTool(element, measurementData, timepointData.timepointID);
activateTool(element, measurementData, timepointData.timepointId);
} else {
cornerstone.loadAndCacheImage(imageIds[imageIdIndex]).then(function(image) {
cornerstone.displayImage(element, image);
activateTool(element, measurementData, timepointData.timepointID);
activateTool(element, measurementData, timepointData.timepointId);
});
}
};
@@ -50,9 +50,9 @@ activateMeasurements = function(element, measurementId, templateData, viewportIn
*
* @param element
* @param measurementData
* @param timepointID
* @param timepointId
*/
function activateTool(element, measurementData, timepointID) {
function activateTool(element, measurementData, timepointId) {
deactivateAllToolData(element, 'lesion');
deactivateAllToolData(element, 'nonTarget');
@@ -62,7 +62,7 @@ function activateTool(element, measurementData, timepointID) {
return;
}
var measurementAtTimepoint = measurementData.timepoints[timepointID];
var measurementAtTimepoint = measurementData.timepoints[timepointId];
for (var i = 0; i < toolData.data.length; i++) {
data = toolData.data[i];
@@ -0,0 +1,59 @@
/**
* Calculates total lesion burden given the Trial Criteria Type.
* Supports RECIST 1.1 and irRC at present, defaults to RECIST.
*
* @param criteriaType Either 'RECIST' or 'irRC'
* @returns {*}
*/
calculateTotalLesionBurden = function(criteriaType) {
var totalBurden;
var measurements = Measurements.find({
isTarget: true
});
switch (criteriaType) {
default:
case 'RECIST':
// - Time Point Measurement Total =
// Sum of long axis measurements for extranodal target lesion +
// short axis measurements for nodal lesions
var sumLongAxisExtranodal = 0,
sumShortAxisNodal = 0;
measurements.forEach(function(measurement) {
var LD = parseFloat(measurement.longestDiameter);
var SD = parseFloat(measurement.longestDiameter);
if (measurement.nodal === true) {
sumShortAxisNodal += SD;
} else {
sumLongAxisExtranodal += LD;
}
});
totalBurden = sumLongAxisExtranodal + sumShortAxisNodal;
break;
case 'irRC':
// - Time Point Measurement Total = SPD target lesions + SPD new lesions
// (SPD = sum of product of long axis and short axis diameters)
var sumProductLesions = 0,
sumProductNewLesions = 0;
measurements.forEach(function(measurement) {
var LD = parseFloat(measurement.longestDiameter);
var SD = parseFloat(measurement.shortestDiameter);
var product = LD * SD;
if (measurement.newLesion === true) {
sumProductNewLesions += product;
} else {
sumProductLesions += product;
}
});
totalBurden = sumProductLesions + sumProductNewLesions;
break;
}
return totalBurden;
};
@@ -19,12 +19,11 @@ clearMeasurementTimepointData = function(measurementId, timepointId) {
delete data.timepoints[timepointId];
if (Object.keys(data.timepoints).length === 0) {
if (!Object.keys(data.timepoints).length) {
Meteor.call('removeMeasurement', measurementId, function(error, response) {
if (error) {
log.warn(error);
}
console.log('Removed!');
});
} else {
// Update the Timepoint object of the Measurement document
+3
View File
@@ -35,4 +35,7 @@ clearTools = function() {
// Remove patient's measurements
Meteor.call('removeMeasurementsByPatientId', patientId);
// Clear all validation errors
ValidationErrors.remove({});
};
@@ -0,0 +1,44 @@
/**
* Calculates a Timepoint's name based on how many timepoints exist between it
* and the latest Baseline. Names returned are in the form of 'Baseline', or
* 'Follow-up 1', 'Follow-up 2', and so on.
*
* @param timepoint
* @returns {*} The timepoint name
*/
getTimepointName = function(timepoint) {
// Check if this is a Baseline timepoint, if it is, return 'Baseline'
if (timepoint.timepointType === 'baseline') {
return 'Baseline';
}
// Retrieve all of the relevant follow-up timepoints for this patient
var followupTimepoints = Timepoints.find({
patientId: timepoint.patientId,
timepointType: timepoint.timepointType
}, {
sort: {
latestDate: 1
}
});
// Create an array of just timepointIds, so we can use indexOf
// on it to find the current timepoint's relative position
var followupTimepointIds = followupTimepoints.map(function(timepoint) {
return timepoint.timepointId;
});
// Calculate the index of the current timepoint in the array of all
// relevant follow-up timepoints
var index = followupTimepointIds.indexOf(timepoint.timepointId) + 1;
// If index is 0, it means that the current timepoint was not in the list
// Log a warning and return here
if (!index) {
log.warn('Current follow-up was not in the list of relevant follow-ups?');
return;
}
// Return the timepoint name as 'Follow-up N'
return 'Follow-up ' + index;
};
@@ -11,6 +11,8 @@ getTimepointObject = function(imageId) {
}
return Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
};
@@ -0,0 +1,12 @@
handleMeasurementAdded = function(e, eventData) {
log.info('CornerstoneToolsMeasurementAdded');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
LesionManager.updateLesionData(measurementData);
TrialResponseCriteria.validateDelayed(measurementData);
break;
}
};
@@ -0,0 +1,12 @@
handleMeasurementModified = function(e, eventData) {
log.info('CornerstoneToolsMeasurementModified');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
LesionManager.updateLesionData(measurementData);
TrialResponseCriteria.validateDelayed(measurementData);
break;
}
};
@@ -0,0 +1,19 @@
handleMeasurementRemoved = function(e, eventData) {
log.info('CornerstoneToolsMeasurementRemoved');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
var measurement = Measurements.findOne(measurementData.id, {
reactive: false
});
if (!measurement) {
return;
}
clearMeasurementTimepointData(measurement._id, measurementData.timepointId);
break;
}
};
@@ -0,0 +1,30 @@
pixelSpacingAutorunCheck = function() {
log.info('lesionTool button change autorun');
/*if (!Session.get('ViewerData')) {
return;
}*/
// Get oncology tools
var oncologyTools = $('button#lesion, button#nonTarget');
// TODO: Set activeViewport for empty viewport element
var activeViewportIndex = Session.get('activeViewport');
if (activeViewportIndex === undefined) {
return;
}
var element = $('.imageViewerViewport').get(activeViewportIndex);
var enabledElement = cornerstone.getEnabledElement(element);
// Check value of rowPixelSpacing & columnPixelSpacing to define as unavailable
if (!enabledElement ||
!enabledElement.image ||
!enabledElement.image.rowPixelSpacing ||
!enabledElement.image.columnPixelSpacing) {
// Disable Lesion Buttons
oncologyTools.prop('disabled', true);
} else {
// Enable Lesion Buttons
oncologyTools.prop('disabled', false);
}
};
@@ -0,0 +1,75 @@
syncMeasurementAndToolData = function(data) {
// Check what toolType we should be adding this to, based on the isTarget value
// of the stored Measurement
var toolType = data.isTarget ? 'lesion' : 'nonTarget';
var toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.toolState;
// Loop through the timepoint data for this measurement
Object.keys(data.timepoints).forEach(function(key) {
var storedData = data.timepoints[key];
var imageId = storedData.imageId;
if (!toolState[imageId]) {
toolState[imageId] = {};
}
// This is probably not the best approach to prevent duplicates
if (toolState[imageId][toolType] && toolState[imageId][toolType].data) {
var measurementHasNoIdYet = false;
toolState[imageId][toolType].data.forEach(function(measurement) {
if (measurement.id === 'notready') {
measurementHasNoIdYet = true;
return false;
}
});
// Stop here if it appears that we are creating this measurement right now,
// and would not like this function to add another copy of it to the toolData
if (measurementHasNoIdYet === true) {
return;
}
}
if (!toolState[imageId][toolType]) {
toolState[imageId][toolType] = {
data: []
};
} else {
var alreadyExists = false;
if (toolState[imageId][toolType].data.length) {
toolState[imageId][toolType].data.forEach(function(measurement) {
if (measurement.id === data._id) {
alreadyExists = true;
// Update the toolData lesionNumber from the Measurement
measurement.lesionNumber = data.lesionNumber;
return false;
}
});
}
if (alreadyExists === true) {
return;
}
}
// Create measurementData structure based on the lesion data at this timepoint
// We will add this into the toolData for this imageId
var measurementData = storedData;
measurementData.isTarget = data.isTarget;
measurementData.lesionNumber = data.lesionNumber;
measurementData.measurementText = data.measurementText;
measurementData.isDeleted = data.isDeleted;
measurementData.location = data.location;
measurementData.locationUID = data.locationUID;
measurementData.patientId = patientId;
measurementData.visible = data.visible;
measurementData.active = data.active;
measurementData.uid = data.uid;
measurementData.id = data._id;
toolState[imageId][toolType].data.push(measurementData);
TrialResponseCriteria.validateSingleMeasurement(measurementData);
});
};
@@ -0,0 +1,13 @@
updateRelatedElements = function(imageId) {
// Get all on-screen elements with this imageId
var enabledElements = cornerstone.getEnabledElementsByImageId(imageId);
// TODO=Check original event to prevent duplicate updateImage calls
// Loop through these elements
enabledElements.forEach(function(enabledElement) {
// Update the display so the tool is removed
var element = enabledElement.element;
cornerstone.updateImage(element);
});
};
-250
View File
@@ -1,250 +0,0 @@
// uuid.js
//
// Copyright (c) 2010-2012 Robert Kieffer
// MIT License - http://opensource.org/licenses/mit-license.php
(function() {
var _global = this;
// Unique ID creation requires a high quality random # generator. We feature
// detect to determine the best RNG source, normalizing to a function that
// returns 128-bits of randomness, since that's what's usually required
var _rng;
// Allow for MSIE11 msCrypto
var _crypto = _global.crypto || _global.msCrypto;
// Node.js crypto-based RNG - http://nodejs.org/docs/v0.6.2/api/crypto.html
//
// Moderately fast, high quality
if (typeof(_global.require) == 'function') {
try {
var _rb = _global.require('crypto').randomBytes;
_rng = _rb && function() {return _rb(16);};
} catch(e) {}
}
if (!_rng && _crypto && _crypto.getRandomValues) {
// WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
//
// Moderately fast, high quality
var _rnds8 = new Uint8Array(16);
_rng = function whatwgRNG() {
_crypto.getRandomValues(_rnds8);
return _rnds8;
};
}
if (!_rng) {
// Math.random()-based (RNG)
//
// If all else fails, use Math.random(). It's fast, but is of unspecified
// quality.
var _rnds = new Array(16);
_rng = function() {
for (var i = 0, r; i < 16; i++) {
if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
_rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
}
return _rnds;
};
}
// Buffer class to use
var BufferClass = typeof(_global.Buffer) == 'function' ? _global.Buffer : Array;
// Maps for number <-> hex string conversion
var _byteToHex = [];
var _hexToByte = {};
for (var i = 0; i < 256; i++) {
_byteToHex[i] = (i + 0x100).toString(16).substr(1);
_hexToByte[_byteToHex[i]] = i;
}
// **`parse()` - Parse a UUID into it's component bytes**
function parse(s, buf, offset) {
var i = (buf && offset) || 0, ii = 0;
buf = buf || [];
s.toLowerCase().replace(/[0-9a-f]{2}/g, function(oct) {
if (ii < 16) { // Don't overflow!
buf[i + ii++] = _hexToByte[oct];
}
});
// Zero out remaining bytes if string was short
while (ii < 16) {
buf[i + ii++] = 0;
}
return buf;
}
// **`unparse()` - Convert UUID byte array (ala parse()) into a string**
function unparse(buf, offset) {
var i = offset || 0, bth = _byteToHex;
return bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]];
}
// **`v1()` - Generate time-based UUID**
//
// Inspired by https://github.com/LiosK/UUID.js
// and http://docs.python.org/library/uuid.html
// random #'s we need to init node and clockseq
var _seedBytes = _rng();
// Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
var _nodeId = [
_seedBytes[0] | 0x01,
_seedBytes[1], _seedBytes[2], _seedBytes[3], _seedBytes[4], _seedBytes[5]
];
// Per 4.2.2, randomize (14 bit) clockseq
var _clockseq = (_seedBytes[6] << 8 | _seedBytes[7]) & 0x3fff;
// Previous uuid creation time
var _lastMSecs = 0, _lastNSecs = 0;
// See https://github.com/broofa/node-uuid for API details
function v1(options, buf, offset) {
var i = buf && offset || 0;
var b = buf || [];
options = options || {};
var clockseq = options.clockseq != null ? options.clockseq : _clockseq;
// UUID timestamps are 100 nano-second units since the Gregorian epoch,
// (1582-10-15 00:00). JSNumbers aren't precise enough for this, so
// time is handled internally as 'msecs' (integer milliseconds) and 'nsecs'
// (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00.
var msecs = options.msecs != null ? options.msecs : new Date().getTime();
// Per 4.2.1.2, use count of uuid's generated during the current clock
// cycle to simulate higher resolution clock
var nsecs = options.nsecs != null ? options.nsecs : _lastNSecs + 1;
// Time since last uuid creation (in msecs)
var dt = (msecs - _lastMSecs) + (nsecs - _lastNSecs) / 10000;
// Per 4.2.1.2, Bump clockseq on clock regression
if (dt < 0 && options.clockseq == null) {
clockseq = clockseq + 1 & 0x3fff;
}
// Reset nsecs if clock regresses (new clockseq) or we've moved onto a new
// time interval
if ((dt < 0 || msecs > _lastMSecs) && options.nsecs == null) {
nsecs = 0;
}
// Per 4.2.1.2 Throw error if too many uuids are requested
if (nsecs >= 10000) {
throw new Error('uuid.v1(): Can\'t create more than 10M uuids/sec');
}
_lastMSecs = msecs;
_lastNSecs = nsecs;
_clockseq = clockseq;
// Per 4.1.4 - Convert from unix epoch to Gregorian epoch
msecs += 12219292800000;
// `time_low`
var tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
b[i++] = tl >>> 24 & 0xff;
b[i++] = tl >>> 16 & 0xff;
b[i++] = tl >>> 8 & 0xff;
b[i++] = tl & 0xff;
// `time_mid`
var tmh = (msecs / 0x100000000 * 10000) & 0xfffffff;
b[i++] = tmh >>> 8 & 0xff;
b[i++] = tmh & 0xff;
// `time_high_and_version`
b[i++] = tmh >>> 24 & 0xf | 0x10; // include version
b[i++] = tmh >>> 16 & 0xff;
// `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
b[i++] = clockseq >>> 8 | 0x80;
// `clock_seq_low`
b[i++] = clockseq & 0xff;
// `node`
var node = options.node || _nodeId;
for (var n = 0; n < 6; n++) {
b[i + n] = node[n];
}
return buf ? buf : unparse(b);
}
// **`v4()` - Generate random UUID**
// See https://github.com/broofa/node-uuid for API details
function v4(options, buf, offset) {
// Deprecated - 'format' argument, as supported in v1.2
var i = buf && offset || 0;
if (typeof(options) == 'string') {
buf = options == 'binary' ? new BufferClass(16) : null;
options = null;
}
options = options || {};
var rnds = options.random || (options.rng || _rng)();
// Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
rnds[6] = (rnds[6] & 0x0f) | 0x40;
rnds[8] = (rnds[8] & 0x3f) | 0x80;
// Copy bytes to buffer, if provided
if (buf) {
for (var ii = 0; ii < 16; ii++) {
buf[i + ii] = rnds[ii];
}
}
return buf || unparse(rnds);
}
// Export public API
var uuid = v4;
uuid.v1 = v1;
uuid.v4 = v4;
uuid.parse = parse;
uuid.unparse = unparse;
uuid.BufferClass = BufferClass;
if (typeof(module) != 'undefined' && module.exports) {
// Publish as node.js module
module.exports = uuid;
} else if (typeof define === 'function' && define.amd) {
// Publish as AMD module
define(function() {return uuid;});
} else {
// Publish as global (in browsers)
var _previousRoot = _global.uuid;
// **`noConflict()` - (browser only) to reset global 'uuid' var**
uuid.noConflict = function() {
_global.uuid = _previousRoot;
return uuid;
};
_global.uuid = uuid;
}
}).call(this);
@@ -0,0 +1,108 @@
/**
* Opens a new tab in the tabbed worklist environment using
* a given timepoint and new tab title.
*
* @param timepointId The UID of the Timepoint to be opened
* @param title The title to be used for the tab heading
*/
openNewTabWithTimepoint = function(timepointId, title) {
log.info('openNewTabWithTimepoint: ' + timepointId + ' ' + 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();
var timepoint = Timepoints.findOne({
timepointId: timepointId
});
if (!timepoint) {
throw 'No such timepoint exists';
}
// Get the relevant studyInstanceUids given the timepoints
var data = getDataFromTimepoint(timepoint);
if (!data.studyInstanceUids) {
throw 'No studies found that are related to this timepoint';
}
// Create a new entry in the WorklistTabs Collection
WorklistTabs.insert({
title: title,
contentid: contentid,
active: false,
timepointId: timepointId
});
// Update the ViewerData global object
ViewerData[contentid] = {
title: title,
contentid: contentid,
studyInstanceUids: data.studyInstanceUids,
timepointIds: data.timepointIds
};
// Switch to the new tab
switchToTab(contentid);
};
/**
* Retrieves related studies given a Baseline or Follow-up Timepoint
*
* @param timepoint
* @returns {Array}
*/
function getDataFromTimepoint(timepoint) {
var relatedStudies = [];
// Include the specified studyInstanceUids
// NOTE: Temporarily added [0], since we only need one study per timepoint to load immediately?
relatedStudies = relatedStudies.concat(timepoint.studyInstanceUids[0]);
// If this is the baseline, we should stop here and return the relevant studies
if (isBaseline(timepoint)) {
return {
studyInstanceUids: relatedStudies,
timepointIds: [timepoint.timepointId]
};
}
// Otherwise, this is a follow-up exam, so we should also find the baseline timepoint,
// and all studies related to it. We also enforce that the Baseline should have a studyDate
// prior to the latest studyDate in the current (Follow-up) Timepoint.
var baseline = Timepoints.findOne({
timepointType: 'baseline',
patientId: timepoint.patientId,
latestDate: {
$lte: timepoint.latestDate
}
});
var timepointIds = [];
if (baseline) {
// NOTE: Temporarily added [0], since we only need one study per timepoint to load immediately?
relatedStudies = relatedStudies.concat(baseline.studyInstanceUids[0]);
timepointIds.push(baseline.timepointId);
} else {
log.warn('No Baseline found while opening a Follow-up Timepoint');
}
timepointIds.push(timepoint.timepointId);
return {
studyInstanceUids: relatedStudies,
timepointIds: timepointIds
};
}
/**
* Checks if a Timepoints is a baseline or not
* (abstracting this for later use, since I expect it to get more complex)
*
* @param timepoint a document from the Timepoints Collection
* @returns {boolean} Whether or not the timepoint is stored as a Baseline
*/
function isBaseline(timepoint) {
return (timepoint.timepointType === 'baseline');
}
@@ -0,0 +1,37 @@
Meteor.startup(function() {
Worklist.subscriptions = ['studies', 'timepoints'];
Worklist.callbacks['dblClickOnStudy'] = dblClickOnStudy;
Worklist.callbacks['middleClickOnStudy'] = dblClickOnStudy;
});
/**
* Lesion Tracker method including Timepoints / other studies
*/
function dblClickOnStudy(data) {
// Use the formatPN template helper to clean up the patient name
var title = formatPN(data.patientName);
var study = Studies.findOne({
studyInstanceUid: data.studyInstanceUid
});
// Check if the study has been associated, and if not, just open it on its own
if (!study) {
// Open a new tab with this study
openNewTab(data.studyInstanceUid, title);
return;
}
// Find the relevant timepoint given the clicked-on study
var timepoint = Timepoints.findOne({
studyInstanceUids: {
$in: [data.studyInstanceUid]
}
});
if (!timepoint) {
openNewTab(data.studyInstanceUid, title);
return;
}
openNewTabWithTimepoint(timepoint.timepointId, title);
}
+80 -7
View File
@@ -10,16 +10,28 @@ Package.onUse(function(api) {
api.use('standard-app-packages');
api.use('jquery');
api.use('stylus');
// Control over logging
api.use('practicalmeteor:loglevel');
// Unique IDs
api.use('rwatts:uuid');
// Template overriding
api.use('aldeed:template-extension@4.0.0');
// Our custom package
api.use('worklist');
api.use('cornerstone');
api.addFiles('log.js', [ 'client', 'server' ]);
// Client-side collections
api.addFiles('client/collections/LesionLocations.js', 'client');
api.addFiles('client/collections/LocationResponses.js', 'client');
api.addFiles('client/collections/PatientLocations.js', 'client');
// Additional Custom Cornerstone Tools for Lesion Tracker
api.addFiles('client/compatibility/lesionTool.js', 'client', {
bare: true
});
@@ -32,14 +44,27 @@ Package.onUse(function(api) {
api.addFiles('client/compatibility/deleteLesionKeyboardTool.js', 'client', {
bare: true
});
api.addFiles('client/compatibility/LesionManager.js', 'client', {
// Trial Criteria data validation (the Meteor package is currently out-of-date)
api.addFiles('client/compatibility/validate.js', 'client', {
bare: true
});
// UI Components
api.addFiles('client/components/lesionTrackerLayout/lesionTrackerLayout.html', 'client');
api.addFiles('client/components/lesionTrackerLayout/lesionTrackerLayout.styl', 'client');
api.addFiles('client/components/associationModal/associationModal.html', 'client');
api.addFiles('client/components/associationModal/associationModal.styl', 'client');
api.addFiles('client/components/associationModal/associationModal.js', 'client');
api.addFiles('client/components/optionsButton/optionsButton.html', 'client');
api.addFiles('client/components/optionsButton/optionsButton.styl', 'client');
api.addFiles('client/components/optionsModal/optionsModal.html', 'client');
api.addFiles('client/components/optionsModal/optionsModal.styl', 'client');
api.addFiles('client/components/optionsModal/optionsModal.js', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.html', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.js', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.styl', 'client');
@@ -56,8 +81,8 @@ Package.onUse(function(api) {
api.addFiles('client/components/lesionTableTimepointCell/lesionTableTimepointCell.js', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.html', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.js', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.styl', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.js', 'client');
api.addFiles('client/components/nonTargetLesionDialog/nonTargetLesionDialog.html', 'client');
api.addFiles('client/components/nonTargetLesionDialog/nonTargetLesionDialog.styl', 'client');
@@ -75,6 +100,10 @@ Package.onUse(function(api) {
api.addFiles('client/components/confirmDeleteDialog/confirmDeleteDialog.styl', 'client');
api.addFiles('client/components/confirmDeleteDialog/confirmDeleteDialog.js', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.html', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.styl', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.js', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.html', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.styl', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.js', 'client');
@@ -82,6 +111,17 @@ Package.onUse(function(api) {
api.addFiles('client/components/timepointTextDialog/timepointTextDialog.html', 'client');
api.addFiles('client/components/timepointTextDialog/timepointTextDialog.styl', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.html', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.styl', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.js', 'client');
api.addFiles('client/components/lesionTrackerWorklistContextMenu/lesionTrackerWorklistContextMenu.html', 'client');
api.addFiles('client/components/lesionTrackerWorklistContextMenu/lesionTrackerWorklistContextMenu.js', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.html', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.styl', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.js', 'client');
// Server functions
api.addFiles('server/collections.js', 'server');
api.addFiles('server/removeCollections.js', [ 'server' ]);
@@ -89,34 +129,67 @@ Package.onUse(function(api) {
// Both client and server functions
api.addFiles('both/collections.js', [ 'client', 'server' ]);
// Worklist-related functions
api.addFiles('lib/worklist/openNewTabWithTimepoint.js', 'client');
api.addFiles('lib/worklist/worklistModification.js', 'client');
// Library functions
api.addFiles('lib/uuid.js', 'client');
api.addFiles('lib/TrialCriteriaConstraints.js', 'client');
api.addFiles('lib/TrialResponseCriteria.js', 'client');
api.addFiles('lib/LesionManager.js', 'client');
api.addFiles('lib/pixelSpacingAutorunCheck.js', 'client');
api.addFiles('lib/toggleLesionTrackerTools.js', 'client');
api.addFiles('lib/clearMeasurementTimepointData.js', 'client');
api.addFiles('lib/removeToolDataWithMeasurementId.js', 'client');
api.addFiles('lib/getTimepointObject.js', 'client');
api.addFiles('lib/getTimepointName.js', 'client');
api.addFiles('lib/activateMeasurements.js', 'client');
api.addFiles('lib/activateLesion.js', 'client');
api.addFiles('lib/deactivateAllToolData.js', 'client');
api.addFiles('lib/clearTools.js', 'client');
api.addFiles('lib/calculateTotalLesionBurden.js', 'client');
api.addFiles('lib/syncMeasurementAndToolData.js', 'client');
api.addFiles('lib/updateRelatedElements.js', 'client');
api.addFiles('lib/handleMeasurementAdded.js', 'client');
api.addFiles('lib/handleMeasurementModified.js', 'client');
api.addFiles('lib/handleMeasurementRemoved.js', 'client');
// Export global functions
api.export('activateLesion','client');
api.export('activateMeasurements','client');
api.export('deactivateAllToolData','client');
api.export('pixelSpacingAutorunCheck', 'client');
api.export('handleMeasurementAdded', 'client');
api.export('handleMeasurementModified', 'client');
api.export('handleMeasurementRemoved', 'client');
api.export('syncMeasurementAndToolData', 'client');
api.export('updateRelatedElements', 'client');
api.export('openNewTabWithTimepoint', 'client');
api.export('activateLesion', 'client');
api.export('activateMeasurements', 'client');
api.export('deactivateAllToolData', 'client');
api.export('toggleLesionTrackerTools', 'client');
api.export('clearMeasurementTimepointData', 'client');
api.export('removeToolDataWithMeasurementId', 'client');
api.export('getTimepointObject', 'client');
api.export('clearTools', 'client');
api.export('getTimepointName', 'client');
api.export('getTrialCriteriaConstraints', 'client');
api.export('calculateTotalLesionBurden', 'client');
// Export global objects
api.export('TrialResponseCriteria', 'client');
api.export('TrialCriteriaConstraints', 'client');
api.export('LesionManager', 'client');
// Export client-side collections
api.export('ValidationErrors', 'client');
api.export('LesionLocations', 'client');
api.export('LocationResponses', 'client');
api.export('PatientLocations', 'client');
// Export collections spanning both client and server
api.export('Measurements', [ 'client', 'server' ]);
api.export('Studies', [ 'client', 'server' ]);
api.export('Timepoints', [ 'client', 'server' ]);
});
+10 -2
View File
@@ -1,10 +1,18 @@
Meteor.publish('timepoints', function(patientId) {
Meteor.publish('timepoints', function() {
return Timepoints.find();
});
Meteor.publish('singlePatientTimepoints', function(patientId) {
return Timepoints.find({
patientId: patientId
});
});
Meteor.publish('measurements', function(patientId) {
Meteor.publish('studies', function() {
return Studies.find();
});
Meteor.publish('singlePatientMeasurements', function(patientId) {
return Measurements.find({
patientId: patientId
});
@@ -43,5 +43,11 @@ Meteor.methods({
Measurements.remove({
patientId: patientId
});
},
clearAllTimepoints: function() {
Timepoints.remove({});
},
removeTimepoint: function(id) {
Timepoints.remove(id);
}
});
@@ -10,7 +10,7 @@ function getElementIfNotEmpty(viewportIndex) {
element = imageViewerViewports.get(viewportIndex),
canvases = imageViewerViewports.eq(viewportIndex).find('canvas');
if (!element || $(element).hasClass("empty") || canvases.length === 0) {
if (!element || $(element).hasClass('empty') || canvases.length === 0) {
return;
}
@@ -20,6 +20,7 @@ function getElementIfNotEmpty(viewportIndex) {
} catch(error) {
return;
}
return element;
}
@@ -28,10 +29,12 @@ function getPatient(property) {
if (!this.imageId) {
return false;
}
var patient = cornerstoneTools.metaData.get('patient', this.imageId);
if (!patient) {
return '';
}
return patient[property];
}
@@ -40,10 +43,12 @@ function getStudy(property) {
if (!this.imageId) {
return false;
}
var study = cornerstoneTools.metaData.get('study', this.imageId);
if (!study) {
return '';
}
return study[property];
}
@@ -52,10 +57,12 @@ function getSeries(property) {
if (!this.imageId) {
return false;
}
var series = cornerstoneTools.metaData.get('series', this.imageId);
if (!series) {
return '';
}
return series[property];
}
@@ -64,10 +71,12 @@ function getInstance(property) {
if (!this.imageId) {
return false;
}
var instance = cornerstoneTools.metaData.get('instance', this.imageId);
if (!instance) {
return '';
}
return instance[property];
}
@@ -76,15 +85,18 @@ function getImage(viewportIndex) {
if (!element) {
return false;
}
var enabledElement;
try {
enabledElement = cornerstone.getEnabledElement(element);
} catch(error) {
return false;
}
if (!enabledElement || !enabledElement.image) {
return false;
}
return enabledElement.image;
}
@@ -95,10 +107,12 @@ Template.viewportOverlay.helpers({
if (!element) {
return '';
}
var viewport = cornerstone.getViewport(element);
if (!viewport) {
return '';
}
return 'W ' + viewport.voi.windowWidth.toFixed(0) + ' L ' + viewport.voi.windowCenter.toFixed(0);
},
zoom: function() {
@@ -107,10 +121,12 @@ Template.viewportOverlay.helpers({
if (!element) {
return '';
}
var viewport = cornerstone.getViewport(element);
if (!viewport) {
return '';
}
return (viewport.scale * 100.0);
},
imageDimensions: function() {
@@ -118,41 +134,42 @@ Template.viewportOverlay.helpers({
var image = getImage(this.viewportIndex);
if (!image) {
return '';
return '';
}
return image.width + ' x ' + image.height;
},
patientName : function() {
patientName: function() {
return getPatient.call(this, 'name');
},
patientId : function() {
patientId: function() {
return getPatient.call(this, 'id');
},
studyDate : function() {
studyDate: function() {
return getStudy.call(this, 'studyDate');
},
studyTime : function() {
studyTime: function() {
return getStudy.call(this, 'studyTime');
},
studyDescription : function() {
studyDescription: function() {
return getStudy.call(this, 'studyDescription');
},
seriesDescription : function() {
seriesDescription: function() {
return getSeries.call(this, 'seriesDescription');
},
seriesNumber : function() {
seriesNumber: function() {
return getSeries.call(this, 'seriesNumber');
},
imageNumber : function() {
imageNumber: function() {
return getInstance.call(this, 'instanceNumber');
},
imageIndex : function() {
imageIndex: function() {
return getInstance.call(this, 'index');
},
numImages : function() {
numImages: function() {
return getSeries.call(this, 'numImages');
},
prior : function() {
prior: function() {
// This helper is updated whenever a new image is displayed in the viewport
Session.get('CornerstoneNewImage' + this.viewportIndex);
if (!this.imageId) {
@@ -165,15 +182,19 @@ Template.viewportOverlay.helpers({
// that we can obtain the oldest study as the first element of the array
//
// TODO= Find out if we should encode studyDate as a Date in the ViewerStudies Collection
var viewportStudies = ViewerStudies.find({}, {sort: {studyDate: 1}});
var viewportStudies = ViewerStudies.find({}, {
sort: {
studyDate: 1
}
});
if (viewportStudies.count() < 2) {
return;
}
// Get study data
var study = cornerstoneTools.metaData.get('study', this.imageId);
if (study.studyInstanceUid === viewportStudies.fetch()[0].studyInstanceUid) {
if (study.studyDate === viewportStudies.fetch()[0].studyDate) {
return 'Prior';
}
}
});
});
@@ -1,6 +1,7 @@
.imageViewerViewportOverlay
font-family: Helvetica, OpenSans
font-size: 13px
color: #e4ad00
color: #979797
line-height: 18px
display: none // Shown when an image is loaded
+72 -56
View File
@@ -7,40 +7,55 @@ Meteor.startup(function() {
}
OHIF.viewer.defaultHotkeys = {
defaultTool: "ESC",
angle: "A",
stackScroll: "S",
pan: "P",
magnify: "M",
scrollDown: ["DOWN", "PAGEDOWN"],
scrollUp: ["UP", "PAGEUP"],
nextPanel: "RIGHT",
previousPanel: "LEFT",
invert: "I",
flipV: "V",
flipH: "H",
wwwc: "W",
zoom: "Z",
cinePlay: "SPACE",
rotateR: "R",
rotateL: "L",
defaultTool: 'ESC',
angle: 'A',
stackScroll: 'S',
pan: 'P',
magnify: 'M',
scrollDown: ['DOWN', 'PAGEDOWN'],
scrollUp: ['UP', 'PAGEUP'],
nextPanel: 'RIGHT',
previousPanel: 'LEFT',
invert: 'I',
flipV: 'V',
flipH: 'H',
wwwc: 'W',
zoom: 'Z',
cinePlay: 'SPACE',
rotateR: 'R',
rotateL: 'L',
toggleOverlayTags: 'SHIFT',
WLPresetSoftTissue: ["NUMPAD1", "1"],
WLPresetLung: ["NUMPAD2", "2"],
WLPresetLiver: ["NUMPAD3", "3"],
WLPresetBone: ["NUMPAD4", "4"],
WLPresetBrain: ["NUMPAD5", "5"]
WLPresetSoftTissue: ['NUMPAD1', '1'],
WLPresetLung: ['NUMPAD2', '2'],
WLPresetLiver: ['NUMPAD3', '3'],
WLPresetBone: ['NUMPAD4', '4'],
WLPresetBrain: ['NUMPAD5', '5']
};
// For now
OHIF.viewer.hotkeys = OHIF.viewer.defaultHotkeys;
OHIF.viewer.defaultWLPresets = {
'SoftTissue' : {wc : 40, ww : 400},
'Lung' : {wc : -600, ww : 1500},
'Liver' : {wc : 90, ww : 150},
'Bone' : {wc: 480, ww : 2500},
'Brain' : {wc : 40, ww : 80}
SoftTissue: {
wc: 40,
ww: 400
},
Lung: {
wc: -600,
ww: 1500
},
Liver: {
wc: 90,
ww: 150
},
Bone: {
wc: 480,
ww: 2500
},
Brain: {
wc: 40,
ww: 80
}
};
// For now
@@ -48,53 +63,53 @@ Meteor.startup(function() {
OHIF.viewer.hotkeyFunctions = {
wwwc: function() {
toolManager.setActiveTool("wwwc");
toolManager.setActiveTool('wwwc');
},
zoom: function() {
toolManager.setActiveTool("zoom");
toolManager.setActiveTool('zoom');
},
angle: function() {
toolManager.setActiveTool("angle");
toolManager.setActiveTool('angle');
},
dragProbe: function() {
toolManager.setActiveTool("dragProbe");
toolManager.setActiveTool('dragProbe');
},
ellipticalRoi: function() {
toolManager.setActiveTool("ellipticalRoi");
toolManager.setActiveTool('ellipticalRoi');
},
magnify: function() {
toolManager.setActiveTool("magnify");
toolManager.setActiveTool('magnify');
},
annotate: function() {
toolManager.setActiveTool("annotate");
toolManager.setActiveTool('annotate');
},
stackScroll: function() {
toolManager.setActiveTool("stackScroll");
toolManager.setActiveTool('stackScroll');
},
pan: function() {
toolManager.setActiveTool("pan");
toolManager.setActiveTool('pan');
},
length: function() {
toolManager.setActiveTool("length");
toolManager.setActiveTool('length');
},
spine: function() {
toolManager.setActiveTool("spine");
toolManager.setActiveTool('spine');
},
wwwcRegion: function() {
toolManager.setActiveTool("wwwcRegion");
toolManager.setActiveTool('wwwcRegion');
},
zoomIn: function () {
var button = document.getElementById("zoomIn");
zoomIn: function() {
var button = document.getElementById('zoomIn');
flashButton(button);
zoomIn();
},
zoomOut: function () {
var button = document.getElementById("zoomOut");
zoomOut: function() {
var button = document.getElementById('zoomOut');
flashButton(button);
zoomOut();
},
zoomToFit: function () {
var button = document.getElementById("zoomToFit");
zoomToFit: function() {
var button = document.getElementById('zoomToFit');
flashButton(button);
zoomToFit();
},
@@ -134,27 +149,27 @@ Meteor.startup(function() {
previousActivePanel();
},
invert: function() {
var button = document.getElementById("invert");
var button = document.getElementById('invert');
flashButton(button);
invert();
},
flipV: function() {
var button = document.getElementById("flipV");
var button = document.getElementById('flipV');
flashButton(button);
flipV();
},
flipH: function() {
var button = document.getElementById("flipH");
var button = document.getElementById('flipH');
flashButton(button);
flipH();
},
rotateR: function() {
var button = document.getElementById("rotateR");
var button = document.getElementById('rotateR');
flashButton(button);
rotateR();
},
rotateL: function() {
var button = document.getElementById("rotateL");
var button = document.getElementById('rotateL');
flashButton(button);
rotateL();
},
@@ -174,6 +189,8 @@ Meteor.startup(function() {
}
}
};
OHIF.viewer.loadedSeriesData = {};
});
// Define a jQuery reverse function
@@ -198,7 +215,6 @@ function previousActivePanel() {
setActiveViewport(element);
}
function nextActivePanel() {
log.info('nextActivePanel');
var currentIndex = Session.get('activeViewport');
@@ -224,7 +240,7 @@ function flashButton(button) {
}
button.classList.add('active');
setTimeout(function () {
setTimeout(function() {
button.classList.remove('active');
}, 100);
}
@@ -233,13 +249,13 @@ function bindHotkey(hotkey, task) {
var hotkeyFunctions = OHIF.viewer.hotkeyFunctions;
// Only bind defined, non-empty HotKeys
if (!hotkey || hotkey === "") {
if (!hotkey || hotkey === '') {
return;
}
var fn;
if (task.indexOf("WLPreset") > -1) {
var presetName = task.replace("WLPreset", "");
if (task.indexOf('WLPreset') > -1) {
var presetName = task.replace('WLPreset', '');
fn = function() {
applyWLPresetToActiveElement(presetName);
};
@@ -282,4 +298,4 @@ enableHotkeys = function() {
bindHotkey(taskHotkeys, task);
}
});
};
};
+12 -4
View File
@@ -29,8 +29,14 @@ getWADORSImageId = function(instance) {
//maxPixelValue : 255,
slope: instance.rescaleSlope,
intercept: instance.rescaleIntercept,
windowCenter : windowCenter,
windowWidth : windowWidth,
samplesPerPixel: instance.samplesPerPixel,
imageOrientationPatient: instance.imageOrientationPatient,
imagePositionPatient: instance.imagePositionPatient,
sopClassUid: instance.sopClassUid,
instanceNumber: instance.instanceNumber,
frameOfReferenceUID: instance.frameOfReferenceUID,
windowCenter: windowCenter,
windowWidth: windowWidth,
//render: cornerstone.renderColorImage,
//getPixelData: getPixelData,
//getImageData: getImageData,
@@ -47,6 +53,8 @@ getWADORSImageId = function(instance) {
instance: instance
};
var imageId = cornerstoneWADORSImageLoader.addImage(image);
var imageId = cornerstoneWADOImageLoader.imageManager.add(image);
console.log('WADO-RS ImageID: ' + imageId);
return imageId;
};
};
+11 -6
View File
@@ -1,9 +1,14 @@
/**
* Formats a patient name for display purposes
*/
formatPN = function(context) {
if (!context) {
return;
}
return context.replace('^', ', ');
};
/**
* A global Blaze UI helper to format a patient name for display purposes
*/
UI.registerHelper('formatPN', function (context) {
if (!context) {
return undefined;
}
return context.replace('^', ', ');
});
UI.registerHelper('formatPN', formatPN);
+1 -1
View File
@@ -7,4 +7,4 @@ UI.registerHelper("inlineIf", function (value, match, attributeName) {
return attributeName;
}
return '';
});
});
@@ -0,0 +1,20 @@
/**
* Helper function to determine if the current client devices
* is touch-capable. This can be used to modify certain aspects of the UI.
*
* The check may not work on all devices!
*
* @returns {boolean} true if the client device is touch-capable, false otherwise
*/
isTouchDevice = function() {
return (('ontouchstart' in window) ||
(navigator.MaxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0));
};
/**
* Blaze helper for checking if the current device is touch capable
*
* @returns {boolean} true if the client device is touch-capable, false otherwise
*/
UI.registerHelper('isTouchDevice', isTouchDevice);
-13
View File
@@ -1,13 +0,0 @@
/**
* Helper function to determine if the current client devices
* is touch-capable. This can be used to modify certain aspects of the UI.
*
* The check may not work on all devices!
*
* @returns {boolean} true if the client device is touch-capable, false otherwise
*/
isTouchDevice = function() {
return (('ontouchstart' in window) ||
(navigator.MaxTouchPoints > 0) ||
(navigator.msMaxTouchPoints > 0));
};
@@ -46,6 +46,7 @@ addMetaData = function(imageId, data) {
instanceNumber: instanceMetaData.instanceNumber,
laterality: instanceMetaData.laterality,
viewPosition: instanceMetaData.viewPosition,
sliceThickness: instanceMetaData.sliceThickness,
index: imageIndex
};
@@ -1 +0,0 @@
+18 -4
View File
@@ -16,7 +16,9 @@ Package.onUse(function (api) {
api.use('practicalmeteor:loglevel');
api.use('momentjs:moment');
// Our custom package
// Our custom packages
api.use('dicomweb');
api.use('dimseservice');
api.use('cornerstone');
api.use('hangingprotocols');
@@ -107,7 +109,6 @@ Package.onUse(function (api) {
api.addFiles('lib/createStacks.js', 'client');
api.addFiles('lib/getImageId.js', 'client');
api.addFiles('lib/getWADORSImageId.js', 'client');
api.addFiles('lib/isTouchDevice.js', 'client');
api.addFiles('lib/metaDataProvider.js', 'client');
api.addFiles('lib/rerenderViewportWithNewSeries.js', 'client');
api.addFiles('lib/sortStudy.js', 'client');
@@ -125,6 +126,7 @@ Package.onUse(function (api) {
api.addFiles('lib/setFocusToActiveViewport.js', 'client');
api.addFiles('lib/encodeQueryData.js', 'server');
//api.export('accountsConfig', 'client');
api.export('resizeViewportElements','client');
api.export('handleResize','client');
@@ -135,7 +137,6 @@ Package.onUse(function (api) {
api.export('createStacks', 'client');
api.export('getImageId', 'client');
api.export('getWADORSImageId', 'client');
api.export('isTouchDevice', 'client');
api.export('metaDataProvider', 'client');
api.export('rerenderViewportWithNewSeries', 'client');
api.export('sortStudy', 'client');
@@ -153,18 +154,31 @@ Package.onUse(function (api) {
// UI Helpers
api.addFiles('lib/helpers/formatDA.js', 'client');
api.addFiles('lib/helpers/formatNumberPrecision.js', 'client');
api.addFiles('lib/helpers/formatPN.js', 'client');
api.addFiles('lib/helpers/formatTM.js', 'client');
api.addFiles('lib/helpers/inlineIf.js', 'client');
api.addFiles('lib/helpers/formatPN.js', 'client');
api.export('formatPN', 'client');
api.addFiles('lib/helpers/isTouchDevice.js', 'client');
api.export('isTouchDevice', 'client');
// Server-side functions
api.addFiles('server/seed.js', 'server');
api.addFiles('server/lib/namespace.js', 'server');
api.addFiles('server/methods/getStudyMetadata.js', 'server');
api.addFiles('server/methods/worklistSearch.js', 'server');
// DICOMWeb instance, study, and metadata retrieval
api.addFiles('server/services/qido/instances.js', 'server');
api.addFiles('server/services/qido/studies.js', 'server');
api.addFiles('server/services/wado/retrieveMetadata.js', 'server');
// DIMSE instance, study, and metadata retrieval
api.addFiles('server/services/dimse/instances.js', 'server');
api.addFiles('server/services/dimse/studies.js', 'server');
api.addFiles('server/services/dimse/retrieveMetadata.js', 'server');
api.export('Services', 'server');
});
+2 -1
View File
@@ -1,3 +1,4 @@
Services = {};
Services.QIDO = {};
Services.WADO = {};
Services.WADO = {};
Services.DIMSE = {};
@@ -3,13 +3,17 @@ Meteor.methods({
* Retrieves Study metadata given a Study Instance UID
* This Meteor method is available from both the client and the server
*/
'GetStudyMetadata' : function(studyInstanceUid) {
GetStudyMetadata: function(studyInstanceUid) {
log.info('GetStudyMetadata(%s)', studyInstanceUid);
if (!Meteor.settings.dicomWeb) {
throw 'No properly configured server was available over DICOMWeb';
}
// Get the server data. This is user-defined in the
// config.json files used to run the Meteor server
var server = Meteor.settings.dicomWeb.endpoints[0];
return Services.WADO.RetrieveMetadata(server, studyInstanceUid);
}
});
});
@@ -3,16 +3,17 @@ Meteor.methods({
* Use the specified filter to conduct a search from the DICOM server
* @param filter
*/
'WorklistSearch': function(filter) {
if (!Meteor.settings.dicomWeb) {
console.warn('No dicomWeb settings provided! Worklist search is not possible!')
return;
}
// Get the server data. This is user-defined in the
// config.json files used to run the Meteor server
var server = Meteor.settings.dicomWeb.endpoints[0];
WorklistSearch: function(filter) {
if (Meteor.settings.dicomWeb && Meteor.settings.defaultServiceType === 'dicomWeb') {
// Get the server data. This is user-defined in the
// config.json files used to run the Meteor server
var server = Meteor.settings.dicomWeb.endpoints[0];
return Services.QIDO.Studies(server, filter);
return Services.QIDO.Studies(server, filter);
} else if (Meteor.settings.dimse && Meteor.settings.defaultServiceType === 'dimse') {
return Services.DIMSE.Studies(filter);
} else {
throw 'No properly configured server was available over DICOMWeb or DIMSE.';
}
}
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Parses data returned from a study search and transforms it into
* an array of series that are present in the study
*
* @param resultData
* @returns {Array} Series List
*/
function resultDataToStudyMetadata(resultData) {
var seriesMap = {};
var seriesList = [];
resultData.forEach(function(instanceRaw) {
var instance = instanceRaw.toObject();
// Use seriesMap to cache series data
// If the series instance UID has already been used to
// process series data, continue using that series
var seriesInstanceUid = instance[0x0020000E];
var series = seriesMap[seriesInstanceUid];
// If no series data exists in the seriesMap cache variable,
// process any available series data
if (!series) {
series = {
seriesInstanceUid: seriesInstanceUid,
seriesNumber: instance[0x00200011],
instances: []
};
// Save this data in the seriesMap cache variable
seriesMap[seriesInstanceUid] = series;
seriesList.push(series);
}
var host = Meteor.settings.dimse.host,
port = Meteor.settings.dimse.port;
var serverRoot = host + ':' + port;
log.info('INSTANCE');
log.info(instance);
var sopInstanceUid = instance[0x00080018];
var uri = serverRoot + '/studies/' + studyInstanceUid + '/series/' + seriesInstanceUid + '/instances/' + sopInstanceUid + '/frames/1';
// Add this instance to the current series
series.instances.push({
sopClassUid: instance[0x00080016],
sopInstanceUid: sopInstanceUid,
uri: uri,
instanceNumber: instance[0x00200013]
});
});
return seriesList;
}
/**
* Retrieve a set of instances using a DIMSE call
* @param studyInstanceUid
* @returns {{wadoUriRoot: String, studyInstanceUid: String, seriesList: Array}}
*/
Services.DIMSE.Instances = function(studyInstanceUid) {
//var url = buildUrl(server, studyInstanceUid);
var result = DIMSE.retrieveInstances(studyInstanceUid);
console.log("DIMSE Instance retrieval");
console.log(result);
return {
studyInstanceUid: studyInstanceUid,
seriesList: resultDataToStudyMetadata(result)
};
};
@@ -0,0 +1,162 @@
/**
* Parses the SourceImageSequence, if it exists, in order
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
* is used to refer to this image in any accompanying DICOM-SR documents.
*
* @param instance
* @returns {String} The ReferenceSOPInstanceUID
*/
function getSourceImageInstanceUid(instance) {
// TODO= Parse the whole Source Image Sequence
// This is a really poor workaround for now.
// Later we should probably parse the whole sequence.
var SourceImageSequence = instance[0x00082112];
log.info('SourceImageSequence ' + SourceImageSequence);
if (SourceImageSequence && SourceImageSequence.length) {
return SourceImageSequence[0][0x00081155];
}
}
/**
* Parses result data from a DIMSE search into Study MetaData
* Returns an object populated with study metadata, including the
* series list.
*
* @param resultData
* @returns {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}}
*/
function resultDataToStudyMetadata(studyInstanceUid, resultData) {
var seriesMap = {};
var seriesList = [];
if (!resultData.length) {
return;
}
var anInstance = resultData[0].toObject();
if (!anInstance) {
return;
}
var studyData = {
seriesList: seriesList,
patientName: anInstance[0x00100010],
patientId: anInstance[0x00100020],
accessionNumber: anInstance[0x00080050],
studyDate: anInstance[0x00080020],
modalities: anInstance[0x00080061],
studyDescription: anInstance[0x00081030],
imageCount: anInstance[0x00201208],
studyInstanceUid: anInstance[0x0020000D]
};
resultData.forEach(function(instanceRaw) {
var instance = instanceRaw.toObject();
var seriesInstanceUid = instance[0x0020000E];
var series = seriesMap[seriesInstanceUid];
if (!series) {
series = {
seriesDescription: instance[0x0008103E],
modality: instance[0x00080060],
seriesInstanceUid: seriesInstanceUid,
seriesNumber: parseFloat(instance[0x00200011]),
instances: []
};
seriesMap[seriesInstanceUid] = series;
seriesList.push(series);
}
var sopInstanceUid = instance[0x00080018];
log.info('instance');
log.info(instance);
var instanceSummary = {
imageType: instance[0x00080008],
sopClassUid: instance[0x00080016],
sopInstanceUid: sopInstanceUid,
instanceNumber: parseFloat(instance[0x00200013]),
imagePositionPatient: instance[0x00200032],
imageOrientationPatient: instance[0x00200037],
frameOfReferenceUID: instance[0x00200052],
sliceLocation: parseFloat(instance[0x00201041]),
samplesPerPixel: parseFloat(instance[0x00280002]),
photometricInterpretation: instance[0x00280004],
rows: parseFloat(instance[0x00280010]),
columns: parseFloat(instance[0x00280011]),
pixelSpacing: instance[0x00280030],
bitsAllocated: parseFloat(instance[0x00280100]),
bitsStored: parseFloat(instance[0x00280101]),
highBit: parseFloat(instance[0x00280102]),
pixelRepresentation: parseFloat(instance[0x00280103]),
windowCenter: instance[0x00281050],
windowWidth: instance[0x00281051],
rescaleIntercept: parseFloat(instance[0x00281052]),
rescaleSlope: parseFloat(instance[0x00281053]),
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
laterality: instance[0x00200062],
viewPosition: instance[0x00185101],
numFrames: parseFloat(instance[0x00280008]),
frameRate: parseFloat(instance[0x00181063])
};
var host = Meteor.settings.dimse.host,
port = Meteor.settings.dimse.port;
var serverRoot = host + ':' + port;
instanceSummary.wadorsuri = serverRoot + '/studies/' + studyInstanceUid + '/series/' + seriesInstanceUid + '/instances/' + sopInstanceUid + '/frames/1';
series.instances.push(instanceSummary);
});
return studyData;
}
/**
* Retrieved Study MetaData from a DICOM server using DIMSE
* @param studyInstanceUid
* @returns {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}}
*/
Services.DIMSE.RetrieveMetadata = function(studyInstanceUid) {
console.log('DIMSE RetrieveMetadata');
var result = DIMSE.retrieveInstances(studyInstanceUid, null, {
0x00080050: '',
0x00080061: '',
0x00081030: '',
0x00201208: '',
0x00080060: '',
0x00080008: '',
0x00200032: '',
0x00200037: '',
0x00200052: '',
0x00201041: '',
0x00280002: '',
0x00280004: '',
0x00280030: '',
0x00280100: '',
0x00280101: '',
0x00280102: '',
0x00280103: '',
0x00281050: '',
0x00281051: '',
0x00281052: '',
0x00281053: '',
0x00200062: '',
0x00185101: '',
0x00280008: '',
0x00181063: '',
0x00082112: ''
});
var study = resultDataToStudyMetadata(studyInstanceUid, result);
if (!study) {
study = {};
}
study.studyInstanceUid = studyInstanceUid;
console.log(study);
console.log(study.seriesList[0].instances[0]);
return study;
};
+48
View File
@@ -0,0 +1,48 @@
/**
* Parses resulting data from a QIDO call into a set of Study MetaData
*
* @param resultData
* @returns {Array} An array of Study MetaData objects
*/
function resultDataToStudies(resultData) {
var studies = [];
resultData.forEach(function(studyRaw) {
var study = studyRaw.toObject();
studies.push({
studyInstanceUid: study[0x0020000D],
// 00080005 = SpecificCharacterSet
studyDate: study[0x00080020],
studyTime: study[0x00080030],
accessionNumber: study[0x00080050],
referringPhysicianName: study[0x00080090],
// 00081190 = URL
patientName: study[0x00100010],
patientId: study[0x00100020],
patientBirthdate: study[0x00100030],
patientSex: study[0x00100040],
imageCount: study[0x00201208],
studyId: study[0x00200010],
studyDescription: study[0x00081030],
modalities: study[0x00080061]
});
});
return studies;
}
Services.DIMSE.Studies = function(filter) {
console.log('Services.DIMSE.Studies');
var parameters = {
0x00100010: filter.patientName,
0x00100020: filter.patientId,
0x00080050: filter.accessionNumber,
0x00081030: filter.studyDescription,
0x00100040: "",
0x00201208: "",
0x00080061: ""
};
var results = DIMSE.retrieveStudies(parameters);
return resultDataToStudies(results);
};
@@ -30,10 +30,10 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
// If no series data exists in the seriesMap cache variable,
// process any available series data
if(!series) {
if (!series) {
series = {
seriesInstanceUid : seriesInstanceUid,
seriesNumber : DICOMWeb.getString(instance['00200011']),
seriesInstanceUid: seriesInstanceUid,
seriesNumber: DICOMWeb.getString(instance['00200011']),
instances: []
};
@@ -52,7 +52,7 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
// manually create a WADO-URI from the UIDs
// NOTE: Haven't been able to get Orthanc's WADO-URI to work yet - maybe its not configured?
var sopInstanceUid = DICOMWeb.getString(instance['00080018']);
var uri = server.wadoUriRoot + '?requestType=WADO&studyUID=' + studyInstanceUid + '&seriesUID=' + seriesInstanceUid + '&objectUID=' + sopInstanceUid + "&contentType=application%2Fdicom";
var uri = server.wadoUriRoot + '?requestType=WADO&studyUID=' + studyInstanceUid + '&seriesUID=' + seriesInstanceUid + '&objectUID=' + sopInstanceUid + '&contentType=application%2Fdicom';
// Add this instance to the current series
series.instances.push({
@@ -61,8 +61,8 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
uri: uri,
instanceNumber: DICOMWeb.getString(instance['00200013'])
});
});
return seriesList;
});
return seriesList;
}
/**
@@ -80,4 +80,4 @@ Services.QIDO.Instances = function(server, studyInstanceUid) {
studyInstanceUid: studyInstanceUid,
seriesList: resultDataToStudyMetadata(server, studyInstanceUid, result.data)
};
};
};
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more