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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user