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

This commit is contained in:
Erik Ziegler 2016-01-13 16:17:32 +01:00
parent 6c4eae3699
commit beaa56c0b3
144 changed files with 5050 additions and 2416 deletions

4
.gitignore vendored
View File

@ -2,4 +2,6 @@
docs/
.meteor/local
.meteor/meteorite
node_modules
node_modules
Packages/active-entry/helloworld/
LesionTracker/tests/nightwatch/reports/

View File

@ -7,6 +7,7 @@
"disallowSpacesInFunctionExpression": {
"beforeOpeningRoundBrace": true
},
"disallowSpacesInsideParentheses": true,
"disallowKeywordsOnNewLine": ["else"],
"disallowNewlineBeforeBlockStatements": true,
"requirePaddingNewLinesAfterUseStrict": true,
@ -17,9 +18,12 @@
"requireObjectKeysOnNewLine": true,
"requireSemicolons": true,
"requireSpaceAfterBinaryOperators": true,
"requireSpaceAfterComma": true,
"requireSpacesInFunctionExpression": {
"beforeOpeningCurlyBrace": true
},
"requireSpaceBeforeObjectValues": true,
"requireSpacesInsideObjectBrackets": "all",
"requireSpacesInsideArrayBrackets": "all",
"requireLineBreakAfterVariableAssignment": true,
"requireSpaceBeforeBinaryOperators": ["+", "-", "/", "*", "=", "==", "===", "!=", "!=="],
"disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-"],

View File

@ -48,3 +48,4 @@ clinical:hipaa-audit-log
clinical:hipaa-logger
anti:gagarin@=0.4.11
check
aldeed:template-extension@4.0.0

View File

@ -1,5 +1,6 @@
accounts-base@1.2.2
accounts-password@1.1.4
aldeed:template-extension@4.0.0
anti:gagarin@0.4.11
anti:i18n@0.4.3
arsnebula:reactive-promise@0.9.1
@ -38,6 +39,7 @@ ddp-server@1.2.2
deps@1.0.9
dicomweb@0.0.1
diff-sequence@1.0.1
dimseservice@0.0.1
ecmascript@0.1.6
ecmascript-runtime@0.2.6
ejson@1.0.7
@ -90,6 +92,7 @@ reactive-var@1.0.6
reload@1.1.4
retry@1.0.4
routepolicy@1.0.6
rwatts:uuid@0.0.2
service-configuration@1.0.5
session@1.1.1
sha@1.0.4

View File

@ -1,30 +1,62 @@
// TODO: Move all of this into the LesionTracker package
body
background-color: #202020
.tab-content {
height: calc(100% - 40px);
}
.tab-content
height: calc(100% - 40px)
table#tblStudyList thead > tr {
background-color: #424242;
color: white;
}
table#tblStudyList tbody > tr:nth-child(even) {
background-color: #888;
color: black;
}
table#tblStudyList tbody > tr:nth-child(odd) {
background-color: #BBB;
color: black;
}
table#tblStudyList tbody > tr:hover{
table#tblStudyList thead > tr
background-color: #424242
color: white
table#tblStudyList tbody > tr:nth-child(even)
background-color: #888
color: black
table#tblStudyList tbody > tr:nth-child(odd)
background-color: #BBB
color: black
table#tblStudyList tbody > tr:hover
background-color: #009BD2
color: white
}
table#tblStudyList tbody > tr > td{
table#tblStudyList tbody > tr > td
word-wrap: break-word
}
/* Mobile devices */
@media only screen and (min-device-width : 375px) and (max-device-width : 768px)
.logoContainer
width: 15%
.navbar-brand
img
height: 20px
margin-top: 10px
margin-bottom: 10px
margin-right: 5px
h4.name
padding: 1px
font-size: 0.3em
width: 50%
table#tblStudyList
table-layout: fixed
font-size: 0.7em
#tablist
width: 85%
overflow-x: auto
#toolbar .btn-group button
width: 40px
height: 40px
.studyDateList
margin-top: 5px
@media (max-device-width: 667px)
#toolbar .btn-group button
width: 35px
height: 35px

View File

@ -5,6 +5,7 @@
{{>nonTargetLesionDialog}}
{{>nonTargetResponseDialog}}
{{>timepointTextDialog}}
{{ >conformanceCheckFeedback }}
{{>hidingPanel}}
<div id="viewportAndLesionTable">

View File

@ -3,10 +3,8 @@ Template.viewer.onCreated(function() {
$(window).on('resize', handleResize);
var self = this;
var firstMeasurementsActivated = false;
log.info('viewer onCreated');
var contentId = this.data.contentId;
OHIF = OHIF || {
viewer: {}
@ -16,7 +14,6 @@ Template.viewer.onCreated(function() {
OHIF.viewer.defaultTool = 'wwwc';
OHIF.viewer.refLinesEnabled = true;
OHIF.viewer.isPlaying = {};
var contentId = this.data.contentId;
OHIF.viewer.functionList = {
invert: function(element) {
@ -42,15 +39,17 @@ Template.viewer.onCreated(function() {
toggleLesionTrackerTools: toggleLesionTrackerTools,
clearTools: clearTools,
lesion: function() {
// Used for hotkeys
toolManager.setActiveTool('lesion');
},
nonTarget: function() {
// Used for hotkeys
toolManager.setActiveTool('nonTarget');
}
};
// The hotkey can also be an array (e.g. ["NUMPAD0", "0"])
OHIF.viewer.defaultHotkeys = OHIF.viewer.defaultHotkeys || {};
OHIF.viewer.defaultHotkeys.toggleLesionTrackerTools = 'O';
OHIF.viewer.defaultHotkeys.lesion = 'T'; // Target
OHIF.viewer.defaultHotkeys.nonTarget = 'N'; // Non-target
@ -65,6 +64,10 @@ Template.viewer.onCreated(function() {
};
}
OHIF.viewer.updateImageSynchronizer = new cornerstoneTools.Synchronizer('CornerstoneNewImage', cornerstoneTools.updateImageSynchronizer);
log.info('viewer onCreated');
if (ViewerData[contentId].loadedSeriesData) {
log.info('Reloading previous loadedSeriesData');
OHIF.viewer.loadedSeriesData = ViewerData[contentId].loadedSeriesData;
@ -76,8 +79,15 @@ Template.viewer.onCreated(function() {
ViewerData[contentId].loadedSeriesData = OHIF.viewer.loadedSeriesData;
// Update the viewer data object
ViewerData[contentId].viewportColumns = 2;
ViewerData[contentId].viewportRows = 1;
if (!this.data.timepointIds || this.data.timepointIds.length <= 1) {
// Update the viewer data object
ViewerData[contentId].viewportColumns = 1;
ViewerData[contentId].viewportRows = 1;
} else if (this.data.timepointIds.length > 1) {
ViewerData[contentId].viewportColumns = 2;
ViewerData[contentId].viewportRows = 1;
}
ViewerData[contentId].activeViewport = 0;
Session.set('ViewerData', ViewerData);
}
@ -96,43 +106,29 @@ Template.viewer.onCreated(function() {
self.autorun(function() {
var patientId = Session.get('patientId');
self.subscribe('timepoints', patientId);
self.subscribe('measurements', patientId);
self.subscribe('singlePatientTimepoints', patientId);
self.subscribe('singlePatientMeasurements', patientId);
if (self.subscriptionsReady()) {
ViewerStudies.find().observe({
added: function(study) {
// TODO = Replace this whole section when we have a
// timepoint-to-study association modal
// First, check if we have a timepoint related to this
// study date already
// Find the relevant timepoint given the newly added study
var timepoint = Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
// If we do, stop here
if (timepoint) {
log.warn('A timepoint with that study date already exists!');
if (!timepoint) {
log.warn('Study added to Viewer has not been associated!');
return;
}
// Next, check the first timepoint in the collection
var testTimepoint = Timepoints.findOne({});
// If it relates to another subject, we need to stop here as well
// because the tab may be changing.
if (testTimepoint && testTimepoint.patientId !== study.patientId) {
log.warn('Timepoints collection related to the wrong subject');
return;
}
// Otherwise, we need to add a timepoint related to this study date
log.info('Inserting a new timepoint');
Timepoints.insert({
patientId: study.patientId,
timepointName: study.studyDate,
timepointID: uuid.v4()
// Update the added document with its related timepointId
ViewerStudies.update(study._id, {
$set: {
timepointId: timepoint.timepointId
}
});
}
});
@ -159,12 +155,17 @@ Template.viewer.onCreated(function() {
log.info('Measurement added');
syncMeasurementAndToolData(data);
updateRelatedElements(data.imageId);
TrialResponseCriteria.validateAll();
},
changed: function(data) {
TrialResponseCriteria.validateAllDelayed();
},
removed: function(data) {
log.info('Measurement removed');
// Check that this Measurement actually contains timepoint data
if (!data.timepoints) {
if (!data || !data.timepoints) {
return;
}
@ -175,9 +176,9 @@ Template.viewer.onCreated(function() {
// Remove the measurement from all the imageIds on which it exists
// as toolData
Object.keys(data.timepoints).forEach(function(timepointID) {
Object.keys(data.timepoints).forEach(function(timepointId) {
// Clear the toolData for this timepoint
var imageId = data.timepoints[timepointID].imageId;
var imageId = data.timepoints[timepointId].imageId;
removeToolDataWithMeasurementId(imageId, toolType, measurementId);
});
@ -202,146 +203,29 @@ Template.viewer.onCreated(function() {
viewports.each(function(index, element) {
cornerstone.updateImage(element);
});
ValidationErrors.remove({
measurementId: data._id
});
TrialResponseCriteria.validateAll();
}
});
}
});
OHIF.viewer.updateImageSynchronizer = new cornerstoneTools.Synchronizer('CornerstoneNewImage', cornerstoneTools.updateImageSynchronizer);
});
function updateRelatedElements(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);
});
}
function syncMeasurementAndToolData(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);
});
}
Template.viewer.onRendered(function() {
// Enable hotkeys
enableHotkeys();
// Set lesion tool buttons as disable if pixel spacing is not available for active element
this.autorun(function(){
if (!Session.get('ViewerData') || Session.get('activeViewport') === undefined) {
return;
}
var activeViewportIndex = Session.get('activeViewport');
var viewports = $(".imageViewerViewport");
var element = viewports.get(activeViewportIndex);
// Check element has .empty class
if (element.classList.contains('empty')) {
setLesionToolButtonsDisable(true);
return;
}
try {
var enabledElement = cornerstone.getEnabledElement(element);
if (!enabledElement || !enabledElement.image ||
!enabledElement.image.rowPixelSpacing ||
!enabledElement.image.columnPixelSpacing) {
// Disable Lesion Tools and Buttons
toolManager.setActiveTool("wwwc", viewports);
cornerstoneTools.lesion.disable(element);
cornerstoneTools.nonTarget.disable(element);
setLesionToolButtonsDisable(true);
} else{
// Enable Lesion Buttons
setLesionToolButtonsDisable(false);
}
} catch(error) {
return;
}
});
// Set lesion tool buttons as disabled if pixel spacing is not available for active element
this.autorun(pixelSpacingAutorunCheck);
});
Template.viewer.onDestroyed(function() {
log.info('onDestroyed');
console.log("viewer destroyed!");
console.log('viewer destroyed!');
// Remove the Window resize listener
$(window).off('resize', handleResize);
@ -350,6 +234,9 @@ Template.viewer.onDestroyed(function() {
});
Template.viewer.events({
'CornerstoneToolsMeasurementAdded .imageViewerViewport': function(e, template, eventData) {
handleMeasurementAdded(e, eventData);
},
'CornerstoneToolsMeasurementModified .imageViewerViewport': function(e, template, eventData) {
handleMeasurementModified(e, eventData);
},
@ -357,43 +244,3 @@ Template.viewer.events({
handleMeasurementRemoved(e, eventData);
}
});
function handleMeasurementModified(e, eventData) {
log.info('CornerstoneToolsMeasurementModified');
var measurementData = eventData.measurementData;
switch (eventData.toolType) {
case 'nonTarget':
case 'lesion':
LesionManager.updateLesionData(measurementData);
break;
}
}
function handleMeasurementRemoved(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;
}
}
// Set enablement of Lesion Tools Buttons
function setLesionToolButtonsDisable (status) {
var buttons = [$("button#lesion"), $("button#nonTarget")];
buttons.forEach(function(btn){
btn.prop("disabled", status);
});
}

View File

@ -1,51 +0,0 @@
/* Mobile devices */
@media only screen and (min-device-width : 375px) and (max-device-width : 768px){
.logoContainer {
width: 15%;
}
.navbar-brand img {
height: 20px;
margin-top: 10px;
margin-bottom: 10px;
margin-right: 5px;
}
.navbar-brand h4.name {
padding: 1px;
font-size: 0.3em;
width: 50%;;
}
table#tblStudyList {
table-layout: fixed;
font-size: 0.7em;
}
#tablist {
width: 85%;
overflow-x: auto;
}
#toolbar .btn-group button {
width: 40px;
height: 40px;
}
.studyDateList {
margin-top: 5px;
}
}
@media (max-device-width: 667px) {
#toolbar .btn-group button {
width: 35px;
height: 35px;
}
}

View File

@ -1,5 +0,0 @@
Router.onBeforeAction(function() {
// User is logged in, go ahead and route them
this.next();
});

View File

@ -11,15 +11,17 @@ Object.keys(ViewerData).forEach(function(contentId) {
});
Router.configure({
layoutTemplate: 'layoutLesionTracker',
loadingTemplate: 'layoutLesionTracker'
layoutTemplate: 'lesionTrackerLayout',
loadingTemplate: 'lesionTrackerLayout',
notFoundTemplate: 'notFound'
});
Router.onBeforeAction('loading');
var data = {
additionalTemplates: [
'associationModal'
'associationModal',
'optionsModal'
]
};
@ -35,24 +37,22 @@ Router.route('/worklist', function() {
this.render('worklist', routerOptions);
});
Router.route('/viewer/:_id', {
layoutTemplate: 'layoutLesionTracker',
Router.route('/viewer/timepoints/:_id', {
layoutTemplate: 'lesionTrackerLayout',
name: 'viewer',
onBeforeAction: function() {
log.info('Router GetStudyMetadata');
var timepointId = this.params._id;
var studyInstanceUid = this.params._id;
// Check if this study is already loaded in a tab
// If it is, stop here so we don't keep adding tabs on hot-code reloads
var tab = WorklistTabs.find({
studyInstanceUid: studyInstanceUid
}).fetch();
var tab = WorklistTabs.findOne({
timepointId: timepointId
});
if (tab) {
return;
}
this.render('worklist', routerOptions);
openNewTab(studyInstanceUid);
openNewTabWithTimepoint(timepointId);
}
});

View File

@ -1,55 +0,0 @@
/*****************************/
/* Toggle */
/*****************************/
.dockingContainer, .collapseVertical, .collapseHorizontal {
-o-transition: all 0.3s ease-out;
-ms-transition: all 0.3s ease-out;
-moz-transition: all 0.3s ease-out;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.dockingContainer {
-o-transition: all 0.3s ease-out;
-ms-transition: all 0.3s ease-out;
-moz-transition: all 0.3s ease-out;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.collapseVertical {
opacity: 0;
height: 0;
border: none;
}
.collapseHorizontal {
width: 30px;
}
.btnCollapse{
position: absolute;
top: 0;
left: 0;
display: block;
width: 25px;
height: 25px;
line-height: 25px;
padding: 0;
border: 0;
border-top: 1px solid #e6e6e6;
background: #f6f6f6;
color: #666;
font-size: 0.875rem;
text-align: center;
cursor: pointer;
z-index: 100;
}
.btnCollapseIcon-collapse{
-moz-transform: rotate(180deg);
-ms-transform: rotate(180deg);
-o-transform: rotate(180deg);
-webkit-transform: rotate(180deg);
transform: rotate(180deg);
}

View File

@ -1,30 +1,30 @@
Meteor.startup(function() {
if (Meteor.settings.dicomWeb) {
console.log('dicomWeb settings defined!');
console.log(Meteor.settings);
return;
}
console.log('Using default LesionTracker dicomWeb settings');
Meteor.settings = {
dicomWeb: {
endpoints: [
{
name: 'Orthanc',
wadoUriRootNOTE: 'either this uri is not correct for wado-uri or wado-uri is not configured on orthanc currently',
wadoUriRoot: 'http://localhost:8043/wado',
qidoRoot: 'http://localhost:8042/dicom-web',
wadoRoot: 'http://localhost:8042/dicom-web',
qidoSupportsIncludeField: false,
imageRendering: 'wadouri',
requestOptions: {
endpoints: [{
name: 'Orthanc',
wadoUriRootNOTE: 'either this uri is not correct for wado-uri or wado-uri is not configured on orthanc currently',
wadoUriRoot: 'http://localhost:8043/wado',
qidoRoot: 'http://localhost:8042/dicom-web',
wadoRoot: 'http://localhost:8042/dicom-web',
qidoSupportsIncludeField: false,
imageRendering: 'wadouri',
requestOptions: {
auth: 'orthanc:orthanc',
logRequests: true,
logResponses: false,
logTiming: true
}
}
]
}
}]
},
dimse: {
host: 'localhost',
port: 4242,
hostAE: 'ORTHANC'
},
defaultServiceType: 'dicomWeb'
//defaultServiceType: 'dimse'
};
console.log('Using default LesionTracker settings with service: ' + Meteor.settings.defaultServiceType);
});

View File

@ -77,6 +77,7 @@ reactive-var@1.0.6
reload@1.1.4
retry@1.0.4
routepolicy@1.0.6
rwatts:uuid@0.0.2
service-configuration@1.0.5
session@1.1.1
sha@1.0.4

File diff suppressed because it is too large Load Diff

View File

@ -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;
});

View File

@ -1,4 +1,4 @@
/*! dicom-parser - v1.2.0 - 2015-11-02 | (c) 2014 Chris Hafey | https://github.com/chafey/dicomParser */
/*! dicom-parser - v1.2.1 - 2016-02-07 | (c) 2014 Chris Hafey | https://github.com/chafey/dicomParser */
(function (root, factory) {
// node.js
@ -66,6 +66,7 @@ var dicomParser = (function(dicomParser) {
function getDataSetByteStream(transferSyntax, position) {
if(transferSyntax === '1.2.840.10008.1.2.1.99')
{
// https://github.com/nodeca/pako
if(typeof(pako) === "undefined") {
throw 'dicomParser.parseDicom: deflated transfer syntax encountered but pako not loaded';
}
@ -936,8 +937,9 @@ var dicomParser = (function (dicomParser)
{
throw "dicomParser.ByteStream: missing required parameter 'byteArray'";
}
if((byteArray instanceof Uint8Array) === false) {
throw 'dicomParser.ByteStream: parameter byteArray is not of type Uint8Array';
if((byteArray instanceof Uint8Array) === false &&
(byteArray instanceof Buffer) === false ) {
throw 'dicomParser.ByteStream: parameter byteArray is not of type Uint8Array or Buffer';
}
if(position < 0)
{
@ -2253,7 +2255,7 @@ var dicomParser = (function (dicomParser)
dicomParser = {};
}
dicomParser.version = "1.2.0";
dicomParser.version = "1.2.1";
return dicomParser;
}(dicomParser));

View File

@ -1,30 +1,45 @@
Package.describe({
name: "cornerstone",
summary: "Cornerstone Web-based Medical Imaging libraries",
version: '0.0.1'
name: 'cornerstone',
summary: 'Cornerstone Web-based Medical Imaging libraries',
version: '0.0.1'
});
Package.onUse(function (api) {
Package.onUse(function(api) {
api.versionsFrom('1.2.0.2');
api.use('jquery');
api.use('dicomweb');
api.addFiles('client/cornerstone.js', 'client', {bare: true});
api.addFiles('client/cornerstoneMath.js', 'client', {bare: true});
api.addFiles('client/cornerstoneTools.js', 'client', {bare: true});
api.addFiles('client/cornerstoneWADOImageLoader.js', 'client', {bare: true});
api.addFiles('client/cornerstoneWADORSImageLoader.js', 'client', {bare: true});
api.addFiles('client/dicomParser.js', 'client', {bare: true});
api.addFiles('client/hammer.js', 'client', {bare: true});
api.addFiles('client/cornerstone.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneMath.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneTools.js', 'client', {
bare: true
});
api.addFiles('client/cornerstoneWADOImageLoader.js', 'client', {
bare: true
});
api.addFiles('client/dicomParser.js', 'client', {
bare: true
});
api.addFiles('client/hammer.js', 'client', {
bare: true
});
api.addFiles('client/measurementManager.js', 'client', {bare: true});
api.addFiles('client/measurementManagerExample.js', 'client', {bare: true});
api.addFiles('client/measurementManager.js', 'client', {
bare: true
});
api.addFiles('client/measurementManagerExample.js', 'client', {
bare: true
});
api.export("cornerstone", 'client');
api.export("cornerstoneMath", 'client');
api.export("cornerstoneTools", 'client');
api.export("cornerstoneWADOImageLoader", 'client');
api.export("cornerstoneWADORSImageLoader", 'client');
api.export("dicomParser", ['client', 'server']);
api.export('cornerstone', 'client');
api.export('cornerstoneMath', 'client');
api.export('cornerstoneTools', 'client');
api.export('cornerstoneWADOImageLoader', 'client');
api.export('dicomParser', ['client', 'server']);
});

View File

@ -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;
};

View File

@ -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;
};

View File

@ -9,16 +9,11 @@ Package.onUse(function(api) {
// DICOMWeb API functions
api.addFiles('server/namespace.js', 'server');
api.addFiles('server/getImageFrame.js', 'server');
api.addFiles('server/getJSON.js', 'server');
api.addFiles('server/getName.js', 'server');
api.addFiles('server/getNumber.js', 'server');
api.addFiles('server/getString.js', 'server');
// Helper functions
api.addFiles('lib/findIndexOfString.js', 'server');
api.addFiles('lib/uint8ArrayToString.js', 'server');
api.export('DICOMWeb', 'server');
api.export('DICOMWeb', ['client', 'server']);
});

View File

@ -1,74 +0,0 @@
function findBoundary(header) {
for (var i = 0; i < header.length; i++) {
if (header[i].substr(0,2) === '--') {
return header[i];
}
}
return undefined;
}
function findContentType(header) {
for (var i = 0; i < header.length; i++) {
if (header[i].substr(0,13) === 'Content-Type:') {
return header[i].substr(13).trim();
}
}
return undefined;
}
DICOMWeb.getImageFrame = function(uri, mediaType) {
mediaType = mediaType || 'application/octet-stream';
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
xhr.open('get', uri, true);
xhr.setRequestHeader('Accept', 'multipart/related;type=' + mediaType);
xhr.onreadystatechange = function(oEvent) {
// TODO: consider sending out progress messages here as we receive the pixel data
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// request succeeded, Parse the multi-part mime response
var imageFrameAsArrayBuffer = xhr.response;
var response = new Uint8Array(xhr.response);
// First look for the multipart mime header
var tokenIndex = findIndexOfString(response, '\n\r\n');
if (tokenIndex === -1) {
reject('invalid response - no multipart mime header');
}
var header = uint8ArrayToString(response, 0, tokenIndex);
// Now find the boundary marker
var split = header.split('\r\n');
var boundary = findBoundary(split);
if (!boundary) {
reject('invalid response - no boundary marker');
}
var offset = tokenIndex + 4; // skip over the \n\r\n
// find the terminal boundary marker
var endIndex = findIndexOfString(response, boundary, offset);
if (endIndex === -1) {
reject('invalid response - terminating boundary not found');
}
// return the info for this pixel data
var length = endIndex - offset - 1;
resolve({
contentType: findContentType(split),
arrayBuffer: imageFrameAsArrayBuffer,
offset: offset,
length: length
});
} else {
// request failed, reject the deferred
reject(xhr.response);
}
}
};
xhr.send();
});
};

View File

@ -1,8 +1,8 @@
DICOMWeb.getJSON = function(url, options) {
var getOptions = {
headers: {
Accept: 'application/json'
},
Accept: 'application/json'
}
};
if (options.auth) {

View File

@ -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 ]
},

View File

@ -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 ]
},

View File

@ -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) {

View File

@ -1,2 +1,5 @@
Timepoints = new Meteor.Collection('timepoints');
Studies = new Meteor.Collection('studies');
Measurements = new Meteor.Collection('measurements');
WorklistSubscriptions = ['studies', 'timepoints'];

View File

@ -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
});

View File

@ -0,0 +1 @@
PatientLocations = new Meteor.Collection(null);

View File

@ -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
};
})();

View File

@ -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;
}

View File

@ -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);
});
}

View File

@ -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

View File

@ -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>

View File

@ -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);
}
});
});

View File

@ -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>

View File

@ -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
}
});
}
});

View File

@ -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

View File

@ -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
}
});

View File

@ -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);

View File

@ -1,9 +1,6 @@
<template name="lesionTableTimepointHeader">
<th class="lesionTableTimepointCell">
{{formatDA timepointName "MM/DD/YYYY"}}
{{#if timepointTextFound}}
{{timepointText}}
{{/if}}
{{#if timepointLoaded }}<span class="timepointIndicator">&lt;</span> {{/if}}
<th class="lesionTableTimepointCell" title="Latest study date: {{formatDA latestDate "MM/DD/YYYY"}}"
data-toggle="tooltip" data-placement="top">
{{ timepointName }}
</th>
</template>

View File

@ -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
};
}
});

View File

@ -1,9 +1,10 @@
<template name="layoutLesionTracker">
<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>
{{> yield}}
{{ >optionsButton }}
{{ >yield }}
</template>

View File

@ -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>

View File

@ -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);
}
});

View File

@ -0,0 +1,3 @@
.imageViewerViewportOverlay
.timepointName
color: #32BFFF

View File

@ -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>

View File

@ -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);
}
});
}
});
}

View File

@ -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>

View File

@ -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);
}
});

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,6 @@
// These settings are temporary!
.optionsButton
position: absolute
top: 3px
right: 350px
cursor: pointer

View File

@ -0,0 +1,83 @@
<template name="optionsModal">
<div class="modal" id="optionsModal" tabindex="-1" role="dialog" aria-labelledby="optionsModalLabel">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="optionsModalLabel">Clinical Trial Options</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-12">
<h4>Select a Trial Criteria Type</h4>
<label for="recistCriteria" class="trialCriteriaLabel">
<input type="radio" name="trialCriteria" checked="true" id="recistCriteria" value='RECIST' class="trialCriteria"> RECIST 1.1
</label>
<label for="irRCCriteria" class="trialCriteriaLabel">
<input type="radio" name="trialCriteria" id="irRCCriteria" value='irRC' class="trialCriteria"> irRC
</label>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4>Trial Criteria Descriptions</h4>
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#RECISTDescription" aria-controls="home" role="tab" data-toggle="tab">RECIST 1.1</a>
</li>
<li role="presentation">
<a href="#irRCDescription" aria-controls="profile" role="tab" data-toggle="tab">irRC</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane fade in active" id="RECISTDescription">
<h5>Baseline Checks</h5>
<ul>
<li>Extranodal lesions must be >= 10 mm long axis AND >= double the acquisition slice thickness by CT and MR</li>
<li>Extranodal lesions must be >= 20 mm on chest x-ray (although x-rays rarely used for clinical trial assessment)</li>
<li>Nodal lesions must be >= 15 mm short axis AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 2 target lesions per organ</li>
<li>Up to a max of 5 target lesions total</li>
<li>Non-targets can only be assessed as 'present'</li>
<li>Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)</li>
</ul>
<!-- Time Point Measurement Total = Sum of long axis measurements for extranodal target lesion + short axis measurements for nodal lesions</li>-->
</div>
<div role="tabpanel" class="tab-pane fade" id="irRCDescription">
<h5>Baseline Checks</h5>
<ul>
<li>Target lesions must be >= 10 X 10 mm AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 5 target lesions per organ</li>
<li>Up to a max of 10 target lesions total</li>
<li>Non-targets can only be assessed as 'present'</li>
<li>Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)</li>
</ul>
<h5>New Lesion Checks for Follow-ups</h5>
<ul>
<li>New target lesions must be >= 5 X 5 mm AND >= double the acquisition slice thickness by CT and MR</li>
<li>Up to a max of 5 target lesions per organ</li>
<li>Up to a max of 10 target lesions total</li>
</ul>
<!--Time Point Measurement Total = SPD target lesions + SPD new lesions (SPD = sum of product of long axis and short axis diameters)-->
</div>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4>Additional Options</h4>
<a type="button" class="btn btn-danger clearAllStudyTimepointAssociations">
<i class="fa fa-trash"></i> Clear All Study/Timepoint Associations
</a>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -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);
}
});
}
});

View File

@ -0,0 +1,2 @@
.trialCriteriaLabel
margin: 0 5px

View File

@ -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 }}

View File

@ -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)
*/

View File

@ -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>

View File

@ -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
});
});
}
});

View File

@ -0,0 +1,202 @@
/**
* Retrieve a location name (e.g. Liver Right) from the
* PatientLocations Collection by id, if it exists. Otherwise,
* return an empty string.
*
* @param id
* @returns {*|string}
*/
function getLocationName(id) {
var locationObject = PatientLocations.findOne(id);
if (!locationObject || !locationObject.location) {
return '';
}
return locationObject.location;
}
/**
* Update the Timepoint object for a specific Measurement.
* If no measurement exists yet, one will be created.
*
* Input is toolData from the lesion or nonTarget tool
*
* @param lesionData
*/
function updateLesionData(lesionData) {
var study = Studies.findOne({
studyInstanceUid: lesionData.studyInstanceUid
});
if (!study) {
log.warn('Study is not associated with a timepoint');
return;
}
var timepoint = Timepoints.findOne({
timepointId: study.timepointId
});
if (!timepoint) {
log.warn('Timepoint in an image is not present in the Timepoints Collection?');
return;
}
// Find the specific lesion to be updated
var existingMeasurement;
if (lesionData.id && lesionData.id !== 'notready') {
existingMeasurement = Measurements.findOne(lesionData.id);
} else {
existingMeasurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
}
// Create a structure for the timepointData based
// on this Lesion's toolData
var timepointData = {
seriesInstanceUid: lesionData.seriesInstanceUid,
studyInstanceUid: lesionData.studyInstanceUid,
sopInstanceUid: lesionData.sopInstanceUid,
handles: lesionData.handles,
imageId: lesionData.imageId
};
if (lesionData.isTarget === true) {
timepointData.shortestDiameter = lesionData.widthMeasurement;
timepointData.longestDiameter = lesionData.measurementText;
} else {
timepointData.response = lesionData.response;
}
// If no such lesion exists, we need to add one
if (!existingMeasurement) {
// Create a data structure for the Measurement
// based on the current tool data
var measurement = {
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget,
patientId: lesionData.patientId,
id: lesionData.id
};
// Retrieve the location name given the locationUID
if (lesionData.locationUID !== undefined) {
var locationObj = PatientLocations.findOne({
locationUID: lesionData.locationUID
});
measurement.location = locationObj.location;
measurement.isNodal = locationObj.isNodal;
}
// Add toolData parameters to the Measurement at this Timepoint
measurement.timepoints = {};
measurement.timepoints[timepoint.timepointId] = timepointData;
// Set a flag to prevent duplication of toolData
measurement.toolDataInsertedManually = true;
// Increment and store the absolute Lesion Number for this Measurement
measurement.lesionNumberAbsolute = Measurements.find().count() + 1;
// Insert this into the Measurements Collection
// Save the ID into the toolData (not sure if this works?)
measurement.id = Measurements.insert(measurement);
// Update the database entry so it can be readded next time the study is loaded
Measurements.update(measurement.id, {
$set: {
toolDataInsertedManually: false
}
});
} else {
lesionData.id = existingMeasurement._id;
lesionData.isNodal = existingMeasurement.isNodal;
// Update timepoints from lesion data
existingMeasurement.timepoints[timepoint.timepointId] = timepointData;
Measurements.update(existingMeasurement._id, {
$set: {
timepoints: existingMeasurement.timepoints
}
});
}
}
/**
* Returns new lesion number according to timepointId
* @param timepointId
* @param isTarget
* @returns {*}
*/
function getNewLesionNumber(timepointId, isTarget) {
// Get all current lesion measurements
var numMeasurements = Measurements.find({
isTarget: isTarget
}).count();
// If no measurements exist yet, start at 1
if (!numMeasurements) {
return 1;
}
// Find related measurements (i.e. target or non-target)
var measurements = Measurements.find({
isTarget: isTarget
}, {
sort: {
lesionNumber: 1
}
}).fetch();
// If measurements exist, find the last lesion number
// from the given timepoint
var lesionNumberCounter = 1;
// Search through every Measurement to see which ones
// already have data for this Timepoint, if we find one that
// doesn't have data, we will stop there and use that as the
// current Measurement
measurements.every(function(measurement) {
// If this measurement has no data for this Timepoint,
// use this as the current Measurement
if (!measurement.timepoints[timepointId]) {
lesionNumberCounter = measurement.lesionNumber;
return false;
}
lesionNumberCounter++;
return true;
});
return lesionNumberCounter;
}
/**
* If the current Lesion Number already exists
* for any other timepoint, returns lesion locationUID
* @param lesionData
* @returns {*}
*/
function lesionNumberExists(lesionData) {
var measurement = Measurements.findOne({
lesionNumber: lesionData.lesionNumber,
isTarget: lesionData.isTarget
});
if (!measurement) {
return;
}
return measurement.locationUID;
}
LesionManager = {
updateLesionData: updateLesionData,
getNewLesionNumber: getNewLesionNumber,
lesionNumberExists: lesionNumberExists,
getLocationName: getLocationName
};

View File

@ -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];
};

View File

@ -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
};

View File

@ -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];

View File

@ -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;
};

View File

@ -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

View File

@ -35,4 +35,7 @@ clearTools = function() {
// Remove patient's measurements
Meteor.call('removeMeasurementsByPatientId', patientId);
// Clear all validation errors
ValidationErrors.remove({});
};

View File

@ -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;
};

View File

@ -11,6 +11,8 @@ getTimepointObject = function(imageId) {
}
return Timepoints.findOne({
timepointName: study.studyDate
studyInstanceUids: {
$in: [study.studyInstanceUid]
}
});
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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);
}
};

View File

@ -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);
});
};

View File

@ -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);
});
};

View File

@ -1,250 +0,0 @@
// uuid.js
//
// Copyright (c) 2010-2012 Robert Kieffer
// MIT License - http://opensource.org/licenses/mit-license.php
(function() {
var _global = this;
// Unique ID creation requires a high quality random # generator. We feature
// detect to determine the best RNG source, normalizing to a function that
// returns 128-bits of randomness, since that's what's usually required
var _rng;
// Allow for MSIE11 msCrypto
var _crypto = _global.crypto || _global.msCrypto;
// Node.js crypto-based RNG - http://nodejs.org/docs/v0.6.2/api/crypto.html
//
// Moderately fast, high quality
if (typeof(_global.require) == 'function') {
try {
var _rb = _global.require('crypto').randomBytes;
_rng = _rb && function() {return _rb(16);};
} catch(e) {}
}
if (!_rng && _crypto && _crypto.getRandomValues) {
// WHATWG crypto-based RNG - http://wiki.whatwg.org/wiki/Crypto
//
// Moderately fast, high quality
var _rnds8 = new Uint8Array(16);
_rng = function whatwgRNG() {
_crypto.getRandomValues(_rnds8);
return _rnds8;
};
}
if (!_rng) {
// Math.random()-based (RNG)
//
// If all else fails, use Math.random(). It's fast, but is of unspecified
// quality.
var _rnds = new Array(16);
_rng = function() {
for (var i = 0, r; i < 16; i++) {
if ((i & 0x03) === 0) r = Math.random() * 0x100000000;
_rnds[i] = r >>> ((i & 0x03) << 3) & 0xff;
}
return _rnds;
};
}
// Buffer class to use
var BufferClass = typeof(_global.Buffer) == 'function' ? _global.Buffer : Array;
// Maps for number <-> hex string conversion
var _byteToHex = [];
var _hexToByte = {};
for (var i = 0; i < 256; i++) {
_byteToHex[i] = (i + 0x100).toString(16).substr(1);
_hexToByte[_byteToHex[i]] = i;
}
// **`parse()` - Parse a UUID into it's component bytes**
function parse(s, buf, offset) {
var i = (buf && offset) || 0, ii = 0;
buf = buf || [];
s.toLowerCase().replace(/[0-9a-f]{2}/g, function(oct) {
if (ii < 16) { // Don't overflow!
buf[i + ii++] = _hexToByte[oct];
}
});
// Zero out remaining bytes if string was short
while (ii < 16) {
buf[i + ii++] = 0;
}
return buf;
}
// **`unparse()` - Convert UUID byte array (ala parse()) into a string**
function unparse(buf, offset) {
var i = offset || 0, bth = _byteToHex;
return bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] + '-' +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]] +
bth[buf[i++]] + bth[buf[i++]];
}
// **`v1()` - Generate time-based UUID**
//
// Inspired by https://github.com/LiosK/UUID.js
// and http://docs.python.org/library/uuid.html
// random #'s we need to init node and clockseq
var _seedBytes = _rng();
// Per 4.5, create and 48-bit node id, (47 random bits + multicast bit = 1)
var _nodeId = [
_seedBytes[0] | 0x01,
_seedBytes[1], _seedBytes[2], _seedBytes[3], _seedBytes[4], _seedBytes[5]
];
// Per 4.2.2, randomize (14 bit) clockseq
var _clockseq = (_seedBytes[6] << 8 | _seedBytes[7]) & 0x3fff;
// Previous uuid creation time
var _lastMSecs = 0, _lastNSecs = 0;
// See https://github.com/broofa/node-uuid for API details
function v1(options, buf, offset) {
var i = buf && offset || 0;
var b = buf || [];
options = options || {};
var clockseq = options.clockseq != null ? options.clockseq : _clockseq;
// UUID timestamps are 100 nano-second units since the Gregorian epoch,
// (1582-10-15 00:00). JSNumbers aren't precise enough for this, so
// time is handled internally as 'msecs' (integer milliseconds) and 'nsecs'
// (100-nanoseconds offset from msecs) since unix epoch, 1970-01-01 00:00.
var msecs = options.msecs != null ? options.msecs : new Date().getTime();
// Per 4.2.1.2, use count of uuid's generated during the current clock
// cycle to simulate higher resolution clock
var nsecs = options.nsecs != null ? options.nsecs : _lastNSecs + 1;
// Time since last uuid creation (in msecs)
var dt = (msecs - _lastMSecs) + (nsecs - _lastNSecs) / 10000;
// Per 4.2.1.2, Bump clockseq on clock regression
if (dt < 0 && options.clockseq == null) {
clockseq = clockseq + 1 & 0x3fff;
}
// Reset nsecs if clock regresses (new clockseq) or we've moved onto a new
// time interval
if ((dt < 0 || msecs > _lastMSecs) && options.nsecs == null) {
nsecs = 0;
}
// Per 4.2.1.2 Throw error if too many uuids are requested
if (nsecs >= 10000) {
throw new Error('uuid.v1(): Can\'t create more than 10M uuids/sec');
}
_lastMSecs = msecs;
_lastNSecs = nsecs;
_clockseq = clockseq;
// Per 4.1.4 - Convert from unix epoch to Gregorian epoch
msecs += 12219292800000;
// `time_low`
var tl = ((msecs & 0xfffffff) * 10000 + nsecs) % 0x100000000;
b[i++] = tl >>> 24 & 0xff;
b[i++] = tl >>> 16 & 0xff;
b[i++] = tl >>> 8 & 0xff;
b[i++] = tl & 0xff;
// `time_mid`
var tmh = (msecs / 0x100000000 * 10000) & 0xfffffff;
b[i++] = tmh >>> 8 & 0xff;
b[i++] = tmh & 0xff;
// `time_high_and_version`
b[i++] = tmh >>> 24 & 0xf | 0x10; // include version
b[i++] = tmh >>> 16 & 0xff;
// `clock_seq_hi_and_reserved` (Per 4.2.2 - include variant)
b[i++] = clockseq >>> 8 | 0x80;
// `clock_seq_low`
b[i++] = clockseq & 0xff;
// `node`
var node = options.node || _nodeId;
for (var n = 0; n < 6; n++) {
b[i + n] = node[n];
}
return buf ? buf : unparse(b);
}
// **`v4()` - Generate random UUID**
// See https://github.com/broofa/node-uuid for API details
function v4(options, buf, offset) {
// Deprecated - 'format' argument, as supported in v1.2
var i = buf && offset || 0;
if (typeof(options) == 'string') {
buf = options == 'binary' ? new BufferClass(16) : null;
options = null;
}
options = options || {};
var rnds = options.random || (options.rng || _rng)();
// Per 4.4, set bits for version and `clock_seq_hi_and_reserved`
rnds[6] = (rnds[6] & 0x0f) | 0x40;
rnds[8] = (rnds[8] & 0x3f) | 0x80;
// Copy bytes to buffer, if provided
if (buf) {
for (var ii = 0; ii < 16; ii++) {
buf[i + ii] = rnds[ii];
}
}
return buf || unparse(rnds);
}
// Export public API
var uuid = v4;
uuid.v1 = v1;
uuid.v4 = v4;
uuid.parse = parse;
uuid.unparse = unparse;
uuid.BufferClass = BufferClass;
if (typeof(module) != 'undefined' && module.exports) {
// Publish as node.js module
module.exports = uuid;
} else if (typeof define === 'function' && define.amd) {
// Publish as AMD module
define(function() {return uuid;});
} else {
// Publish as global (in browsers)
var _previousRoot = _global.uuid;
// **`noConflict()` - (browser only) to reset global 'uuid' var**
uuid.noConflict = function() {
_global.uuid = _previousRoot;
return uuid;
};
_global.uuid = uuid;
}
}).call(this);

View File

@ -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');
}

View File

@ -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);
}

View File

@ -10,16 +10,28 @@ Package.onUse(function(api) {
api.use('standard-app-packages');
api.use('jquery');
api.use('stylus');
// Control over logging
api.use('practicalmeteor:loglevel');
// Unique IDs
api.use('rwatts:uuid');
// Template overriding
api.use('aldeed:template-extension@4.0.0');
// Our custom package
api.use('worklist');
api.use('cornerstone');
api.addFiles('log.js', [ 'client', 'server' ]);
// Client-side collections
api.addFiles('client/collections/LesionLocations.js', 'client');
api.addFiles('client/collections/LocationResponses.js', 'client');
api.addFiles('client/collections/PatientLocations.js', 'client');
// Additional Custom Cornerstone Tools for Lesion Tracker
api.addFiles('client/compatibility/lesionTool.js', 'client', {
bare: true
});
@ -32,14 +44,27 @@ Package.onUse(function(api) {
api.addFiles('client/compatibility/deleteLesionKeyboardTool.js', 'client', {
bare: true
});
api.addFiles('client/compatibility/LesionManager.js', 'client', {
// Trial Criteria data validation (the Meteor package is currently out-of-date)
api.addFiles('client/compatibility/validate.js', 'client', {
bare: true
});
// UI Components
api.addFiles('client/components/lesionTrackerLayout/lesionTrackerLayout.html', 'client');
api.addFiles('client/components/lesionTrackerLayout/lesionTrackerLayout.styl', 'client');
api.addFiles('client/components/associationModal/associationModal.html', 'client');
api.addFiles('client/components/associationModal/associationModal.styl', 'client');
api.addFiles('client/components/associationModal/associationModal.js', 'client');
api.addFiles('client/components/optionsButton/optionsButton.html', 'client');
api.addFiles('client/components/optionsButton/optionsButton.styl', 'client');
api.addFiles('client/components/optionsModal/optionsModal.html', 'client');
api.addFiles('client/components/optionsModal/optionsModal.styl', 'client');
api.addFiles('client/components/optionsModal/optionsModal.js', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.html', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.js', 'client');
api.addFiles('client/components/lesionLocationDialog/lesionLocationDialog.styl', 'client');
@ -56,8 +81,8 @@ Package.onUse(function(api) {
api.addFiles('client/components/lesionTableTimepointCell/lesionTableTimepointCell.js', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.html', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.js', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.styl', 'client');
api.addFiles('client/components/lesionTableTimepointHeader/lesionTableTimepointHeader.js', 'client');
api.addFiles('client/components/nonTargetLesionDialog/nonTargetLesionDialog.html', 'client');
api.addFiles('client/components/nonTargetLesionDialog/nonTargetLesionDialog.styl', 'client');
@ -75,6 +100,10 @@ Package.onUse(function(api) {
api.addFiles('client/components/confirmDeleteDialog/confirmDeleteDialog.styl', 'client');
api.addFiles('client/components/confirmDeleteDialog/confirmDeleteDialog.js', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.html', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.styl', 'client');
api.addFiles('client/components/conformanceCheckFeedback/conformanceCheckFeedback.js', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.html', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.styl', 'client');
api.addFiles('client/components/nonTargetResponseDialog/nonTargetResponseDialog.js', 'client');
@ -82,6 +111,17 @@ Package.onUse(function(api) {
api.addFiles('client/components/timepointTextDialog/timepointTextDialog.html', 'client');
api.addFiles('client/components/timepointTextDialog/timepointTextDialog.styl', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.html', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.styl', 'client');
api.addFiles('client/components/lesionTrackerWorklistStudy/lesionTrackerWorklistStudy.js', 'client');
api.addFiles('client/components/lesionTrackerWorklistContextMenu/lesionTrackerWorklistContextMenu.html', 'client');
api.addFiles('client/components/lesionTrackerWorklistContextMenu/lesionTrackerWorklistContextMenu.js', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.html', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.styl', 'client');
api.addFiles('client/components/lesionTrackerViewportOverlay/lesionTrackerViewportOverlay.js', 'client');
// Server functions
api.addFiles('server/collections.js', 'server');
api.addFiles('server/removeCollections.js', [ 'server' ]);
@ -89,34 +129,67 @@ Package.onUse(function(api) {
// Both client and server functions
api.addFiles('both/collections.js', [ 'client', 'server' ]);
// Worklist-related functions
api.addFiles('lib/worklist/openNewTabWithTimepoint.js', 'client');
api.addFiles('lib/worklist/worklistModification.js', 'client');
// Library functions
api.addFiles('lib/uuid.js', 'client');
api.addFiles('lib/TrialCriteriaConstraints.js', 'client');
api.addFiles('lib/TrialResponseCriteria.js', 'client');
api.addFiles('lib/LesionManager.js', 'client');
api.addFiles('lib/pixelSpacingAutorunCheck.js', 'client');
api.addFiles('lib/toggleLesionTrackerTools.js', 'client');
api.addFiles('lib/clearMeasurementTimepointData.js', 'client');
api.addFiles('lib/removeToolDataWithMeasurementId.js', 'client');
api.addFiles('lib/getTimepointObject.js', 'client');
api.addFiles('lib/getTimepointName.js', 'client');
api.addFiles('lib/activateMeasurements.js', 'client');
api.addFiles('lib/activateLesion.js', 'client');
api.addFiles('lib/deactivateAllToolData.js', 'client');
api.addFiles('lib/clearTools.js', 'client');
api.addFiles('lib/calculateTotalLesionBurden.js', 'client');
api.addFiles('lib/syncMeasurementAndToolData.js', 'client');
api.addFiles('lib/updateRelatedElements.js', 'client');
api.addFiles('lib/handleMeasurementAdded.js', 'client');
api.addFiles('lib/handleMeasurementModified.js', 'client');
api.addFiles('lib/handleMeasurementRemoved.js', 'client');
// Export global functions
api.export('activateLesion','client');
api.export('activateMeasurements','client');
api.export('deactivateAllToolData','client');
api.export('pixelSpacingAutorunCheck', 'client');
api.export('handleMeasurementAdded', 'client');
api.export('handleMeasurementModified', 'client');
api.export('handleMeasurementRemoved', 'client');
api.export('syncMeasurementAndToolData', 'client');
api.export('updateRelatedElements', 'client');
api.export('openNewTabWithTimepoint', 'client');
api.export('activateLesion', 'client');
api.export('activateMeasurements', 'client');
api.export('deactivateAllToolData', 'client');
api.export('toggleLesionTrackerTools', 'client');
api.export('clearMeasurementTimepointData', 'client');
api.export('removeToolDataWithMeasurementId', 'client');
api.export('getTimepointObject', 'client');
api.export('clearTools', 'client');
api.export('getTimepointName', 'client');
api.export('getTrialCriteriaConstraints', 'client');
api.export('calculateTotalLesionBurden', 'client');
// Export global objects
api.export('TrialResponseCriteria', 'client');
api.export('TrialCriteriaConstraints', 'client');
api.export('LesionManager', 'client');
// Export client-side collections
api.export('ValidationErrors', 'client');
api.export('LesionLocations', 'client');
api.export('LocationResponses', 'client');
api.export('PatientLocations', 'client');
// Export collections spanning both client and server
api.export('Measurements', [ 'client', 'server' ]);
api.export('Studies', [ 'client', 'server' ]);
api.export('Timepoints', [ 'client', 'server' ]);
});

View File

@ -1,10 +1,18 @@
Meteor.publish('timepoints', function(patientId) {
Meteor.publish('timepoints', function() {
return Timepoints.find();
});
Meteor.publish('singlePatientTimepoints', function(patientId) {
return Timepoints.find({
patientId: patientId
});
});
Meteor.publish('measurements', function(patientId) {
Meteor.publish('studies', function() {
return Studies.find();
});
Meteor.publish('singlePatientMeasurements', function(patientId) {
return Measurements.find({
patientId: patientId
});

View File

@ -43,5 +43,11 @@ Meteor.methods({
Measurements.remove({
patientId: patientId
});
},
clearAllTimepoints: function() {
Timepoints.remove({});
},
removeTimepoint: function(id) {
Timepoints.remove(id);
}
});

View File

@ -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';
}
}
});
});

View File

@ -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

View File

@ -7,40 +7,55 @@ Meteor.startup(function() {
}
OHIF.viewer.defaultHotkeys = {
defaultTool: "ESC",
angle: "A",
stackScroll: "S",
pan: "P",
magnify: "M",
scrollDown: ["DOWN", "PAGEDOWN"],
scrollUp: ["UP", "PAGEUP"],
nextPanel: "RIGHT",
previousPanel: "LEFT",
invert: "I",
flipV: "V",
flipH: "H",
wwwc: "W",
zoom: "Z",
cinePlay: "SPACE",
rotateR: "R",
rotateL: "L",
defaultTool: 'ESC',
angle: 'A',
stackScroll: 'S',
pan: 'P',
magnify: 'M',
scrollDown: ['DOWN', 'PAGEDOWN'],
scrollUp: ['UP', 'PAGEUP'],
nextPanel: 'RIGHT',
previousPanel: 'LEFT',
invert: 'I',
flipV: 'V',
flipH: 'H',
wwwc: 'W',
zoom: 'Z',
cinePlay: 'SPACE',
rotateR: 'R',
rotateL: 'L',
toggleOverlayTags: 'SHIFT',
WLPresetSoftTissue: ["NUMPAD1", "1"],
WLPresetLung: ["NUMPAD2", "2"],
WLPresetLiver: ["NUMPAD3", "3"],
WLPresetBone: ["NUMPAD4", "4"],
WLPresetBrain: ["NUMPAD5", "5"]
WLPresetSoftTissue: ['NUMPAD1', '1'],
WLPresetLung: ['NUMPAD2', '2'],
WLPresetLiver: ['NUMPAD3', '3'],
WLPresetBone: ['NUMPAD4', '4'],
WLPresetBrain: ['NUMPAD5', '5']
};
// For now
OHIF.viewer.hotkeys = OHIF.viewer.defaultHotkeys;
OHIF.viewer.defaultWLPresets = {
'SoftTissue' : {wc : 40, ww : 400},
'Lung' : {wc : -600, ww : 1500},
'Liver' : {wc : 90, ww : 150},
'Bone' : {wc: 480, ww : 2500},
'Brain' : {wc : 40, ww : 80}
SoftTissue: {
wc: 40,
ww: 400
},
Lung: {
wc: -600,
ww: 1500
},
Liver: {
wc: 90,
ww: 150
},
Bone: {
wc: 480,
ww: 2500
},
Brain: {
wc: 40,
ww: 80
}
};
// For now
@ -48,53 +63,53 @@ Meteor.startup(function() {
OHIF.viewer.hotkeyFunctions = {
wwwc: function() {
toolManager.setActiveTool("wwwc");
toolManager.setActiveTool('wwwc');
},
zoom: function() {
toolManager.setActiveTool("zoom");
toolManager.setActiveTool('zoom');
},
angle: function() {
toolManager.setActiveTool("angle");
toolManager.setActiveTool('angle');
},
dragProbe: function() {
toolManager.setActiveTool("dragProbe");
toolManager.setActiveTool('dragProbe');
},
ellipticalRoi: function() {
toolManager.setActiveTool("ellipticalRoi");
toolManager.setActiveTool('ellipticalRoi');
},
magnify: function() {
toolManager.setActiveTool("magnify");
toolManager.setActiveTool('magnify');
},
annotate: function() {
toolManager.setActiveTool("annotate");
toolManager.setActiveTool('annotate');
},
stackScroll: function() {
toolManager.setActiveTool("stackScroll");
toolManager.setActiveTool('stackScroll');
},
pan: function() {
toolManager.setActiveTool("pan");
toolManager.setActiveTool('pan');
},
length: function() {
toolManager.setActiveTool("length");
toolManager.setActiveTool('length');
},
spine: function() {
toolManager.setActiveTool("spine");
toolManager.setActiveTool('spine');
},
wwwcRegion: function() {
toolManager.setActiveTool("wwwcRegion");
toolManager.setActiveTool('wwwcRegion');
},
zoomIn: function () {
var button = document.getElementById("zoomIn");
zoomIn: function() {
var button = document.getElementById('zoomIn');
flashButton(button);
zoomIn();
},
zoomOut: function () {
var button = document.getElementById("zoomOut");
zoomOut: function() {
var button = document.getElementById('zoomOut');
flashButton(button);
zoomOut();
},
zoomToFit: function () {
var button = document.getElementById("zoomToFit");
zoomToFit: function() {
var button = document.getElementById('zoomToFit');
flashButton(button);
zoomToFit();
},
@ -134,27 +149,27 @@ Meteor.startup(function() {
previousActivePanel();
},
invert: function() {
var button = document.getElementById("invert");
var button = document.getElementById('invert');
flashButton(button);
invert();
},
flipV: function() {
var button = document.getElementById("flipV");
var button = document.getElementById('flipV');
flashButton(button);
flipV();
},
flipH: function() {
var button = document.getElementById("flipH");
var button = document.getElementById('flipH');
flashButton(button);
flipH();
},
rotateR: function() {
var button = document.getElementById("rotateR");
var button = document.getElementById('rotateR');
flashButton(button);
rotateR();
},
rotateL: function() {
var button = document.getElementById("rotateL");
var button = document.getElementById('rotateL');
flashButton(button);
rotateL();
},
@ -174,6 +189,8 @@ Meteor.startup(function() {
}
}
};
OHIF.viewer.loadedSeriesData = {};
});
// Define a jQuery reverse function
@ -198,7 +215,6 @@ function previousActivePanel() {
setActiveViewport(element);
}
function nextActivePanel() {
log.info('nextActivePanel');
var currentIndex = Session.get('activeViewport');
@ -224,7 +240,7 @@ function flashButton(button) {
}
button.classList.add('active');
setTimeout(function () {
setTimeout(function() {
button.classList.remove('active');
}, 100);
}
@ -233,13 +249,13 @@ function bindHotkey(hotkey, task) {
var hotkeyFunctions = OHIF.viewer.hotkeyFunctions;
// Only bind defined, non-empty HotKeys
if (!hotkey || hotkey === "") {
if (!hotkey || hotkey === '') {
return;
}
var fn;
if (task.indexOf("WLPreset") > -1) {
var presetName = task.replace("WLPreset", "");
if (task.indexOf('WLPreset') > -1) {
var presetName = task.replace('WLPreset', '');
fn = function() {
applyWLPresetToActiveElement(presetName);
};
@ -282,4 +298,4 @@ enableHotkeys = function() {
bindHotkey(taskHotkeys, task);
}
});
};
};

View File

@ -29,8 +29,14 @@ getWADORSImageId = function(instance) {
//maxPixelValue : 255,
slope: instance.rescaleSlope,
intercept: instance.rescaleIntercept,
windowCenter : windowCenter,
windowWidth : windowWidth,
samplesPerPixel: instance.samplesPerPixel,
imageOrientationPatient: instance.imageOrientationPatient,
imagePositionPatient: instance.imagePositionPatient,
sopClassUid: instance.sopClassUid,
instanceNumber: instance.instanceNumber,
frameOfReferenceUID: instance.frameOfReferenceUID,
windowCenter: windowCenter,
windowWidth: windowWidth,
//render: cornerstone.renderColorImage,
//getPixelData: getPixelData,
//getImageData: getImageData,
@ -47,6 +53,8 @@ getWADORSImageId = function(instance) {
instance: instance
};
var imageId = cornerstoneWADORSImageLoader.addImage(image);
var imageId = cornerstoneWADOImageLoader.imageManager.add(image);
console.log('WADO-RS ImageID: ' + imageId);
return imageId;
};
};

View File

@ -1,9 +1,14 @@
/**
* Formats a patient name for display purposes
*/
formatPN = function(context) {
if (!context) {
return;
}
return context.replace('^', ', ');
};
/**
* A global Blaze UI helper to format a patient name for display purposes
*/
UI.registerHelper('formatPN', function (context) {
if (!context) {
return undefined;
}
return context.replace('^', ', ');
});
UI.registerHelper('formatPN', formatPN);

Some files were not shown because too many files have changed in this diff Show More