Updates for Timepoint assocation (LT-60) and Conformance Checks (LT-92, LT-93, LT-94)
This commit is contained in:
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;
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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.
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+7
@@ -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>
|
||||
+11
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
+19
@@ -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
|
||||
+13
-4
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
+7
-7
@@ -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);
|
||||
|
||||
+3
-6
@@ -1,9 +1,6 @@
|
||||
<template name="lesionTableTimepointHeader">
|
||||
<th class="lesionTableTimepointCell">
|
||||
{{formatDA timepointName "MM/DD/YYYY"}}
|
||||
{{#if timepointTextFound}}
|
||||
{{timepointText}}
|
||||
{{/if}}
|
||||
{{#if timepointLoaded }}<span class="timepointIndicator"><</span> {{/if}}
|
||||
<th class="lesionTableTimepointCell" title="Latest study date: {{formatDA latestDate "MM/DD/YYYY"}}"
|
||||
data-toggle="tooltip" data-placement="top">
|
||||
{{ timepointName }}
|
||||
</th>
|
||||
</template>
|
||||
+4
-103
@@ -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%;
|
||||
|
||||
+26
@@ -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>
|
||||
+30
@@ -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);
|
||||
}
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.imageViewerViewportOverlay
|
||||
.timepointName
|
||||
color: #32BFFF
|
||||
+11
-8
@@ -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>
|
||||
+75
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+31
@@ -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>
|
||||
+30
@@ -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);
|
||||
}
|
||||
});
|
||||
File renamed without changes.
+10
-5
@@ -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
|
||||
|
||||
+1
-1
@@ -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">×</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
|
||||
+7
-5
@@ -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 }}
|
||||
|
||||
+26
-30
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' ]);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user