reorganize packages, start converting overall app to react (very broken at the moment)
This commit is contained in:
1 parent
024fe1a448
commit
5f30f7bd35
479 files changed
+5364
-11611
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements = {};
|
||||
@@ -0,0 +1,2 @@
|
||||
import './measurements.js';
|
||||
import './timepoints.js';
|
||||
@@ -0,0 +1,432 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstoneTools } from 'meteor/ohif:cornerstone';
|
||||
|
||||
let configuration = {};
|
||||
|
||||
class MeasurementApi {
|
||||
static setConfiguration(config) {
|
||||
_.extend(configuration, config);
|
||||
}
|
||||
|
||||
static getConfiguration() {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
static getToolsGroupsMap() {
|
||||
const toolsGroupsMap = {};
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
toolGroup.childTools.forEach(tool => (toolsGroupsMap[tool.id] = toolGroup.id));
|
||||
});
|
||||
return toolsGroupsMap;
|
||||
}
|
||||
|
||||
constructor(timepointApi) {
|
||||
if (timepointApi) {
|
||||
this.timepointApi = timepointApi;
|
||||
}
|
||||
|
||||
this.toolGroups = {};
|
||||
this.tools = {};
|
||||
this.toolsGroupsMap = MeasurementApi.getToolsGroupsMap();
|
||||
this.changeObserver = new Tracker.Dependency();
|
||||
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
const groupCollection = new Mongo.Collection(null);
|
||||
groupCollection._debugName = toolGroup.name;
|
||||
groupCollection.attachSchema(toolGroup.schema);
|
||||
this.toolGroups[toolGroup.id] = groupCollection;
|
||||
|
||||
toolGroup.childTools.forEach(tool => {
|
||||
const collection = new Mongo.Collection(null);
|
||||
collection._debugName = tool.name;
|
||||
collection.attachSchema(tool.schema);
|
||||
this.tools[tool.id] = collection;
|
||||
|
||||
const addedHandler = measurement => {
|
||||
let measurementNumber;
|
||||
|
||||
// Get the measurement number
|
||||
const timepoint = this.timepointApi.timepoints.findOne({
|
||||
studyInstanceUids: measurement.studyInstanceUid
|
||||
});
|
||||
|
||||
// Preventing errors thrown when non-associated (standalone) study is opened...
|
||||
// @TODO: Make sure this logic is correct.
|
||||
if (!timepoint) return;
|
||||
|
||||
const emptyItem = groupCollection.findOne({
|
||||
toolId: { $eq: null },
|
||||
timepointId: timepoint.timepointId
|
||||
});
|
||||
|
||||
if (emptyItem) {
|
||||
measurementNumber = emptyItem.measurementNumber;
|
||||
|
||||
groupCollection.update({
|
||||
timepointId: timepoint.timepointId,
|
||||
measurementNumber
|
||||
}, {
|
||||
$set: {
|
||||
toolId: tool.id,
|
||||
toolItemId: measurement._id,
|
||||
createdAt: measurement.createdAt
|
||||
}
|
||||
});
|
||||
} else {
|
||||
measurementNumber = groupCollection.find({
|
||||
studyInstanceUid: { $in: timepoint.studyInstanceUids }
|
||||
}).count() + 1;
|
||||
}
|
||||
|
||||
measurement.measurementNumber = measurementNumber;
|
||||
|
||||
// Get the current location/description (if already defined)
|
||||
const updateObject = {
|
||||
timepointId: timepoint.timepointId,
|
||||
measurementNumber
|
||||
};
|
||||
const baselineTimepoint = timepointApi.baseline();
|
||||
const baselineGroupEntry = groupCollection.findOne({
|
||||
timepointId: baselineTimepoint.timepointId
|
||||
});
|
||||
if (baselineGroupEntry) {
|
||||
const tool = this.tools[baselineGroupEntry.toolId];
|
||||
const found = tool.findOne({ measurementNumber });
|
||||
if (found) {
|
||||
updateObject.location = found.location;
|
||||
if (found.description) {
|
||||
updateObject.description = found.description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the timepoint ID, measurement number, location and description
|
||||
collection.update(measurement._id, { $set: updateObject });
|
||||
|
||||
if (!emptyItem) {
|
||||
// Reflect the entry in the tool group collection
|
||||
groupCollection.insert({
|
||||
toolId: tool.id,
|
||||
toolItemId: measurement._id,
|
||||
timepointId: timepoint.timepointId,
|
||||
studyInstanceUid: measurement.studyInstanceUid,
|
||||
createdAt: measurement.createdAt,
|
||||
measurementNumber
|
||||
});
|
||||
}
|
||||
|
||||
// Enable reactivity
|
||||
this.changeObserver.changed();
|
||||
};
|
||||
|
||||
const changedHandler = measurement => {
|
||||
this.changeObserver.changed();
|
||||
};
|
||||
|
||||
const removedHandler = measurement => {
|
||||
const measurementNumber = measurement.measurementNumber;
|
||||
|
||||
groupCollection.update({
|
||||
toolItemId: measurement._id
|
||||
}, {
|
||||
$set: {
|
||||
toolId: null,
|
||||
toolItemId: null
|
||||
}
|
||||
});
|
||||
|
||||
const nonEmptyItem = groupCollection.findOne({
|
||||
measurementNumber,
|
||||
toolId: { $not: null }
|
||||
});
|
||||
|
||||
if (nonEmptyItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupItems = groupCollection.find({ measurementNumber }).fetch();
|
||||
|
||||
groupItems.forEach(groupItem => {
|
||||
// Remove the record from the tools group collection too
|
||||
groupCollection.remove({ _id: groupItem._id });
|
||||
|
||||
// Update the measurement numbers only if it is last item
|
||||
const timepoint = this.timepointApi.timepoints.findOne({
|
||||
timepointId: groupItem.timepointId
|
||||
});
|
||||
|
||||
const filter = {
|
||||
studyInstanceUid: { $in: timepoint.studyInstanceUids },
|
||||
measurementNumber
|
||||
};
|
||||
|
||||
const remainingItems = groupCollection.find(filter).count();
|
||||
if (!remainingItems) {
|
||||
filter.measurementNumber = { $gte: measurementNumber };
|
||||
const operator = {
|
||||
$inc: { measurementNumber: -1 }
|
||||
};
|
||||
const options = { multi: true };
|
||||
groupCollection.update(filter, operator, options);
|
||||
toolGroup.childTools.forEach(childTool => {
|
||||
const collection = this.tools[childTool.id];
|
||||
collection.update(filter, operator, options);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Synchronize the new tool data
|
||||
this.syncMeasurementsAndToolData();
|
||||
|
||||
// Enable reactivity
|
||||
this.changeObserver.changed();
|
||||
};
|
||||
|
||||
collection.find().observe({
|
||||
added: addedHandler,
|
||||
changed: changedHandler,
|
||||
removed: removedHandler
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
retrieveMeasurements(patientId, timepointIds) {
|
||||
const retrievalFn = configuration.dataExchange.retrieve;
|
||||
if (!_.isFunction(retrievalFn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
retrievalFn(patientId, timepointIds).then(measurementData => {
|
||||
|
||||
OHIF.log.info('Measurement data retrieval');
|
||||
OHIF.log.info(measurementData);
|
||||
|
||||
const toolsGroupsMap = MeasurementApi.getToolsGroupsMap();
|
||||
const measurementsGroups = {};
|
||||
|
||||
Object.keys(measurementData).forEach(measurementTypeId => {
|
||||
const measurements = measurementData[measurementTypeId];
|
||||
|
||||
measurements.forEach(measurement => {
|
||||
const { toolType } = measurement;
|
||||
if (toolType && this.tools[toolType]) {
|
||||
delete measurement._id;
|
||||
const toolGroup = toolsGroupsMap[toolType];
|
||||
if (!measurementsGroups[toolGroup]) {
|
||||
measurementsGroups[toolGroup] = [];
|
||||
}
|
||||
|
||||
measurementsGroups[toolGroup].push(measurement);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(measurementsGroups).forEach(groupKey => {
|
||||
const group = measurementsGroups[groupKey];
|
||||
group.sort((a, b) => {
|
||||
if (a.measurementNumber > b.measurementNumber) {
|
||||
return 1;
|
||||
} else if (a.measurementNumber < b.measurementNumber) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
group.forEach(m => this.tools[m.toolType].insert(m));
|
||||
});
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
storeMeasurements(timepointId) {
|
||||
const storeFn = configuration.dataExchange.store;
|
||||
if (!_.isFunction(storeFn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let measurementData = {};
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
toolGroup.childTools.forEach(tool => {
|
||||
if (!measurementData[toolGroup.id]) {
|
||||
measurementData[toolGroup.id] = [];
|
||||
}
|
||||
|
||||
measurementData[toolGroup.id] = measurementData[toolGroup.id].concat(this.tools[tool.id].find().fetch());
|
||||
});
|
||||
});
|
||||
|
||||
const timepointFilter = timepointId ? { timepointId } : {};
|
||||
const timepoints = this.timepointApi.all(timepointFilter);
|
||||
const timepointIds = timepoints.map(t => t.timepointId);
|
||||
const patientId = timepoints[0].patientId;
|
||||
const filter = {
|
||||
patientId,
|
||||
timepointId: {
|
||||
$in: timepointIds
|
||||
}
|
||||
};
|
||||
|
||||
OHIF.log.info('Saving Measurements for timepoints:', timepoints);
|
||||
return storeFn(measurementData, filter).then(() => {
|
||||
OHIF.log.info('Measurement storage completed');
|
||||
});
|
||||
}
|
||||
|
||||
validateMeasurements() {
|
||||
const validateFn = configuration.dataValidation.validateMeasurements;
|
||||
if (validateFn && validateFn instanceof Function) {
|
||||
validateFn();
|
||||
}
|
||||
}
|
||||
|
||||
syncMeasurementsAndToolData() {
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
toolGroup.childTools.forEach(tool => {
|
||||
const measurements = this.tools[tool.id].find().fetch();
|
||||
measurements.forEach(measurement => {
|
||||
OHIF.measurements.syncMeasurementAndToolData(measurement);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sortMeasurements(baselineTimepointId) {
|
||||
const tools = configuration.measurementTools;
|
||||
|
||||
const includedTools = tools.filter(tool => {
|
||||
return (tool.options && tool.options.caseProgress && tool.options.caseProgress.include);
|
||||
});
|
||||
|
||||
// Update Measurement the displayed Measurements
|
||||
includedTools.forEach(tool => {
|
||||
const collection = this.tools[tool.id];
|
||||
const measurements = collection.find().fetch();
|
||||
measurements.forEach(measurement => {
|
||||
OHIF.measurements.syncMeasurementAndToolData(measurement);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteMeasurements(measurementTypeId, filter) {
|
||||
const groupCollection = this.toolGroups[measurementTypeId];
|
||||
|
||||
// Stop here if it is a temporary toolGroups
|
||||
if (!groupCollection) return;
|
||||
|
||||
// Get the entries information before removing them
|
||||
const groupItems = groupCollection.find(filter).fetch();
|
||||
const entries = [];
|
||||
groupItems.forEach(groupItem => {
|
||||
if (!groupItem.toolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = this.tools[groupItem.toolId];
|
||||
entries.push(collection.findOne(groupItem.toolItemId));
|
||||
collection.remove(groupItem.toolItemId);
|
||||
});
|
||||
|
||||
// Stop here if no entries were found
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the filter doesn't have the measurement number, get it from the first entry
|
||||
const measurementNumber = filter.measurementNumber || entries[0].measurementNumber;
|
||||
|
||||
// Synchronize the new data with cornerstone tools
|
||||
const toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState();
|
||||
|
||||
_.each(entries, entry => {
|
||||
const measurementsData = [];
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(entry.toolType);
|
||||
if (Array.isArray(tool.childTools)) {
|
||||
tool.childTools.forEach(key => {
|
||||
const childMeasurement = entry[key];
|
||||
if (!childMeasurement) return;
|
||||
measurementsData.push(childMeasurement);
|
||||
});
|
||||
} else {
|
||||
measurementsData.push(entry);
|
||||
}
|
||||
|
||||
measurementsData.forEach(measurementData => {
|
||||
const { imagePath, toolType } = measurementData;
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(imagePath);
|
||||
if (toolState[imageId]) {
|
||||
const toolData = toolState[imageId][toolType];
|
||||
const measurementEntries = toolData && toolData.data;
|
||||
const measurementEntry = _.findWhere(measurementEntries, { _id: entry._id });
|
||||
if (measurementEntry) {
|
||||
const index = measurementEntries.indexOf(measurementEntry);
|
||||
measurementEntries.splice(index, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(toolState);
|
||||
|
||||
// Synchronize the updated measurements with Cornerstone Tools
|
||||
// toolData to make sure the displayed measurements show 'Target X' correctly
|
||||
const syncFilter = _.clone(filter);
|
||||
delete syncFilter.timepointId;
|
||||
|
||||
syncFilter.measurementNumber = {
|
||||
$gt: measurementNumber - 1
|
||||
};
|
||||
|
||||
const toolTypes = _.uniq(entries.map(entry => entry.toolType));
|
||||
toolTypes.forEach(toolType => {
|
||||
const collection = this.tools[toolType];
|
||||
collection.find(syncFilter).forEach(measurement => {
|
||||
OHIF.measurements.syncMeasurementAndToolData(measurement);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getMeasurementById(measurementId) {
|
||||
let foundGroup;
|
||||
_.find(this.toolGroups, toolGroup => {
|
||||
foundGroup = toolGroup.findOne({ toolItemId: measurementId });
|
||||
return !!foundGroup;
|
||||
});
|
||||
|
||||
// Stop here if no group was found or if the record is a placeholder
|
||||
if (!foundGroup || !foundGroup.toolId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.tools[foundGroup.toolId].findOne(measurementId);
|
||||
}
|
||||
|
||||
fetch(toolGroupId, selector, options) {
|
||||
if (!this.toolGroups[toolGroupId]) {
|
||||
throw 'MeasurementApi: No Collection with the id: ' + toolGroupId;
|
||||
}
|
||||
|
||||
selector = selector || {};
|
||||
options = options || {};
|
||||
const result = [];
|
||||
const items = this.toolGroups[toolGroupId].find(selector, options).fetch();
|
||||
items.forEach(item => {
|
||||
if (item.toolId) {
|
||||
result.push(this.tools[item.toolId].findOne(item.toolItemId));
|
||||
} else {
|
||||
result.push({ measurementNumber: item.measurementNumber });
|
||||
}
|
||||
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
OHIF.measurements.MeasurementApi = MeasurementApi;
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import _ from 'underscore';
|
||||
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
import { schema as TimepointSchema } from '../schema/timepoints';
|
||||
|
||||
const configuration = {};
|
||||
|
||||
class TimepointApi {
|
||||
static setConfiguration(config) {
|
||||
_.extend(configuration, config);
|
||||
}
|
||||
|
||||
static getConfiguration() {
|
||||
return configuration;
|
||||
}
|
||||
|
||||
constructor(currentTimepointId, options={}) {
|
||||
if (currentTimepointId) {
|
||||
this.currentTimepointId = currentTimepointId;
|
||||
}
|
||||
|
||||
this.options = options;
|
||||
this.timepoints = new Mongo.Collection(null);
|
||||
this.timepoints.attachSchema(TimepointSchema);
|
||||
this.timepoints._debugName = 'Timepoints';
|
||||
}
|
||||
|
||||
retrieveTimepoints(filter) {
|
||||
const retrievalFn = configuration.dataExchange.retrieve;
|
||||
if (!_.isFunction(retrievalFn)) {
|
||||
OHIF.log.error('Timepoint retrieval function has not been configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
retrievalFn(filter).then(timepointData => {
|
||||
OHIF.log.info('Timepoint data retrieval');
|
||||
|
||||
_.each(timepointData, timepoint => {
|
||||
delete timepoint._id;
|
||||
const query = {
|
||||
timepointId: timepoint.timepointId
|
||||
};
|
||||
|
||||
this.timepoints.update(query, {
|
||||
$set: timepoint
|
||||
}, {
|
||||
upsert: true
|
||||
});
|
||||
});
|
||||
|
||||
resolve();
|
||||
}).catch(reason => {
|
||||
OHIF.log.error(`Timepoint retrieval function failed: ${reason}`);
|
||||
reject(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
storeTimepoints() {
|
||||
const storeFn = configuration.dataExchange.store;
|
||||
if (!_.isFunction(storeFn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepointData = this.timepoints.find().fetch();
|
||||
OHIF.log.info('Preparing to store timepoints');
|
||||
OHIF.log.info(JSON.stringify(timepointData, null, 2));
|
||||
|
||||
storeFn(timepointData).then(() => OHIF.log.info('Timepoint storage completed'));
|
||||
}
|
||||
|
||||
disassociateStudy(timepointIds, studyInstanceUid) {
|
||||
const disassociateFn = configuration.dataExchange.disassociate;
|
||||
disassociateFn(timepointIds, studyInstanceUid).then(() => {
|
||||
OHIF.log.info('Disassociation completed');
|
||||
|
||||
this.timepoints.remove({});
|
||||
this.retrieveTimepoints({});
|
||||
});
|
||||
}
|
||||
|
||||
removeTimepoint(timepointId) {
|
||||
const removeFn = configuration.dataExchange.remove;
|
||||
if (!_.isFunction(removeFn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepointData = {
|
||||
timepointId
|
||||
};
|
||||
|
||||
OHIF.log.info('Preparing to remove timepoint');
|
||||
OHIF.log.info(JSON.stringify(timepointData, null, 2));
|
||||
|
||||
removeFn(timepointData).then(() => {
|
||||
OHIF.log.info('Timepoint removal completed');
|
||||
this.timepoints.remove(timepointData);
|
||||
});
|
||||
}
|
||||
|
||||
updateTimepoint(timepointId, query) {
|
||||
const updateFn = configuration.dataExchange.update;
|
||||
if (!_.isFunction(updateFn)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepointData = {
|
||||
timepointId
|
||||
};
|
||||
|
||||
OHIF.log.info('Preparing to update timepoint');
|
||||
OHIF.log.info(JSON.stringify(timepointData, null, 2));
|
||||
OHIF.log.info(JSON.stringify(query, null, 2));
|
||||
|
||||
updateFn(timepointData, query).then(() => {
|
||||
OHIF.log.info('Timepoint updated completed');
|
||||
this.timepoints.update(timepointData, query);
|
||||
});
|
||||
}
|
||||
|
||||
// Return all timepoints
|
||||
all(filter={}) {
|
||||
return this.timepoints.find(filter, {
|
||||
sort: {
|
||||
latestDate: -1
|
||||
},
|
||||
}).fetch();
|
||||
}
|
||||
|
||||
// Return only the current timepoint
|
||||
current() {
|
||||
return this.timepoints.findOne({ timepointId: this.currentTimepointId });
|
||||
}
|
||||
|
||||
lock() {
|
||||
const current = this.current();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timepoints.update(current._id, {
|
||||
$set: {
|
||||
locked: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the prior timepoint
|
||||
prior() {
|
||||
const current = this.current();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDate = current.latestDate;
|
||||
return this.timepoints.findOne({
|
||||
latestDate: { $lt: latestDate }
|
||||
}, {
|
||||
sort: { latestDate: -1 }
|
||||
});
|
||||
}
|
||||
|
||||
// Return only the current and prior timepoints
|
||||
currentAndPrior() {
|
||||
const timepoints = [];
|
||||
|
||||
const current = this.current();
|
||||
if (current) {
|
||||
timepoints.push(current);
|
||||
}
|
||||
|
||||
const prior = this.prior();
|
||||
if (current && prior && prior._id !== current._id) {
|
||||
timepoints.push(prior);
|
||||
}
|
||||
|
||||
return timepoints;
|
||||
}
|
||||
|
||||
// Return only the comparison timepoints
|
||||
comparison() {
|
||||
return this.currentAndPrior();
|
||||
}
|
||||
|
||||
// Return only the baseline timepoint
|
||||
baseline() {
|
||||
return this.timepoints.findOne({ timepointType: 'baseline' });
|
||||
}
|
||||
|
||||
// Return only the nadir timepoint
|
||||
nadir() {
|
||||
const timepoint = this.timepoints.findOne({ timepointKey: 'nadir' });
|
||||
return timepoint || this.baseline();
|
||||
}
|
||||
|
||||
// Return only the key timepoints (current, prior, nadir and baseline)
|
||||
key(filter={}) {
|
||||
const result = [];
|
||||
|
||||
// Get all the timepoints
|
||||
const all = this.all(filter);
|
||||
|
||||
// Iterate over each timepoint and insert the key ones in the result
|
||||
_.each(all, (timepoint, index) => {
|
||||
if (index < 2 || index === (all.length - 1)) {
|
||||
result.push(timepoint);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the resulting timepoints
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return only the timepoints for the given study
|
||||
study(studyInstanceUid) {
|
||||
const result = [];
|
||||
|
||||
// Iterate over each timepoint and insert the key ones in the result
|
||||
_.each(this.all(), (timepoint, index) => {
|
||||
if (_.contains(timepoint.studyInstanceUids, studyInstanceUid)) {
|
||||
result.push(timepoint);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the resulting timepoints
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return the timepoint's name
|
||||
name(timepoint) {
|
||||
// Check if this is a Baseline timepoint, if it is, return 'Baseline'
|
||||
if (timepoint.timepointType === 'baseline') {
|
||||
return 'Baseline';
|
||||
} else if (timepoint.visitNumber) {
|
||||
return 'Follow-up ' + timepoint.visitNumber;
|
||||
}
|
||||
|
||||
// Retrieve all of the relevant follow-up timepoints for this patient
|
||||
const followupTimepoints = this.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
|
||||
const followupTimepointIds = followupTimepoints.map(timepoint => timepoint.timepointId);
|
||||
|
||||
// Calculate the index of the current timepoint in the array of all
|
||||
// relevant follow-up timepoints
|
||||
const 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) {
|
||||
OHIF.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;
|
||||
}
|
||||
|
||||
// Build the timepoint title based on its date
|
||||
title(timepoint) {
|
||||
const timepointName = this.name(timepoint);
|
||||
|
||||
const all = _.clone(this.all());
|
||||
let index = -1;
|
||||
let currentIndex = null;
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
const currentTimepoint = all[i];
|
||||
|
||||
// Skip the iterations until we can't find the selected timepoint on study list
|
||||
if (this.currentTimepointId === currentTimepoint.timepointId) {
|
||||
currentIndex = 0;
|
||||
}
|
||||
|
||||
if (_.isNumber(currentIndex)) {
|
||||
index = currentIndex++;
|
||||
}
|
||||
|
||||
// Break the loop if reached the timepoint to get the title
|
||||
if (currentTimepoint.timepointId === timepoint.timepointId) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const states = {
|
||||
0: '(Current)',
|
||||
1: '(Prior)'
|
||||
};
|
||||
// TODO: [design] find out how to define the nadir timepoint
|
||||
const parenthesis = states[index] || '';
|
||||
return `${timepointName} ${parenthesis}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
OHIF.measurements.TimepointApi = TimepointApi;
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import 'meteor/ohif:viewerbase';
|
||||
import { CriteriaEvaluator } from './CriteriaEvaluator';
|
||||
import * as evaluations from './evaluations';
|
||||
|
||||
class ConformanceCriteria {
|
||||
|
||||
constructor(measurementApi, timepointApi) {
|
||||
this.measurementApi = measurementApi;
|
||||
this.timepointApi = timepointApi;
|
||||
this.nonconformities = new ReactiveVar();
|
||||
this.groupedNonConformities = new ReactiveVar();
|
||||
this.maxTargets = new ReactiveVar(null);
|
||||
this.maxNewTargets = new ReactiveVar(null);
|
||||
|
||||
const validate = _.debounce(trialCriteriaType => this.validate(trialCriteriaType), 300);
|
||||
Tracker.autorun(() => {
|
||||
const selectedType = OHIF.lesiontracker.TrialCriteriaTypes.findOne({ selected: true });
|
||||
this.measurementApi.changeObserver.depend();
|
||||
validate(selectedType);
|
||||
});
|
||||
}
|
||||
|
||||
validate(trialCriteriaType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const baselinePromise = this.getData('baseline');
|
||||
const followupPromise = this.getData('followup');
|
||||
Promise.all([baselinePromise, followupPromise]).then(values => {
|
||||
const [baselineData, followupData] = values;
|
||||
const mergedData = {
|
||||
targets: [],
|
||||
nonTargets: []
|
||||
};
|
||||
|
||||
mergedData.targets = mergedData.targets.concat(baselineData.targets);
|
||||
mergedData.targets = mergedData.targets.concat(followupData.targets);
|
||||
mergedData.nonTargets = mergedData.nonTargets.concat(baselineData.nonTargets);
|
||||
mergedData.nonTargets = mergedData.nonTargets.concat(followupData.nonTargets);
|
||||
|
||||
this.maxTargets.set(null);
|
||||
this.maxNewTargets.set(null);
|
||||
const resultBoth = this.validateTimepoint('both', trialCriteriaType, mergedData);
|
||||
const resultBaseline = this.validateTimepoint('baseline', trialCriteriaType, baselineData);
|
||||
const resultFollowup = this.validateTimepoint('followup', trialCriteriaType, followupData);
|
||||
const nonconformities = resultBaseline.concat(resultFollowup).concat(resultBoth);
|
||||
const groupedNonConformities = this.groupNonConformities(nonconformities);
|
||||
|
||||
// Keep both? Group the data only on viewer/measurementTable views?
|
||||
// Work with not grouped data (worse lookup performance on measurementTableRow)?
|
||||
this.nonconformities.set(nonconformities);
|
||||
this.groupedNonConformities.set(groupedNonConformities);
|
||||
|
||||
resolve(nonconformities);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
groupNonConformities(nonconformities) {
|
||||
const groups = {};
|
||||
const toolsGroupsMap = this.measurementApi.toolsGroupsMap;
|
||||
|
||||
nonconformities.forEach(nonConformity => {
|
||||
if (nonConformity.isGlobal) {
|
||||
groups.globals = groups.globals || { messages: [] };
|
||||
groups.globals.messages.push(nonConformity.message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
nonConformity.measurements.forEach(measurement => {
|
||||
const groupName = toolsGroupsMap[measurement.toolType];
|
||||
groups[groupName] = groups[groupName] || { measurementNumbers: {} };
|
||||
|
||||
const group = groups[groupName];
|
||||
const measureNumber = measurement.measurementNumber;
|
||||
let measurementNumbers = group.measurementNumbers[measureNumber];
|
||||
|
||||
if (!measurementNumbers) {
|
||||
measurementNumbers = group.measurementNumbers[measureNumber] = {
|
||||
messages: [],
|
||||
measurements: []
|
||||
};
|
||||
}
|
||||
|
||||
measurementNumbers.messages.push(nonConformity.message);
|
||||
measurementNumbers.measurements.push(measurement);
|
||||
});
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
validateTimepoint(timepointId, trialCriteriaType, data) {
|
||||
const evaluators = this.getEvaluators(timepointId, trialCriteriaType);
|
||||
let nonconformities = [];
|
||||
|
||||
evaluators.forEach(evaluator => {
|
||||
const maxTargets = evaluator.getMaxTargets(false);
|
||||
const maxNewTargets = evaluator.getMaxTargets(true);
|
||||
if (maxTargets) {
|
||||
this.maxTargets.set(maxTargets);
|
||||
}
|
||||
|
||||
if (maxNewTargets) {
|
||||
this.maxNewTargets.set(maxNewTargets);
|
||||
}
|
||||
|
||||
const result = evaluator.evaluate(data);
|
||||
nonconformities = nonconformities.concat(result);
|
||||
});
|
||||
|
||||
return nonconformities;
|
||||
}
|
||||
|
||||
getEvaluators(timepointId, trialCriteriaType) {
|
||||
const evaluators = [];
|
||||
const trialCriteriaTypeId = trialCriteriaType.id.toLowerCase();
|
||||
const evaluation = evaluations[trialCriteriaTypeId];
|
||||
|
||||
if (evaluation) {
|
||||
const evaluationTimepoint = evaluation[timepointId];
|
||||
|
||||
if (evaluationTimepoint) {
|
||||
evaluators.push(new CriteriaEvaluator(evaluationTimepoint));
|
||||
}
|
||||
}
|
||||
|
||||
return evaluators;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build the data that will be used to do the conformance criteria checks
|
||||
*/
|
||||
getData(timepointType) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = {
|
||||
targets: [],
|
||||
nonTargets: []
|
||||
};
|
||||
|
||||
const studyPromises = [];
|
||||
|
||||
const fillData = measurementType => {
|
||||
const measurements = this.measurementApi.fetch(measurementType);
|
||||
|
||||
measurements.forEach(measurement => {
|
||||
const { studyInstanceUid } = measurement;
|
||||
|
||||
const timepointId = measurement.timepointId;
|
||||
const timepoint = timepointId && this.timepointApi.timepoints.findOne({ timepointId });
|
||||
|
||||
if (!timepoint || ((timepointType !== 'both') && (timepoint.timepointType !== timepointType))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = OHIF.studies.loadStudy(studyInstanceUid);
|
||||
promise.then(study => {
|
||||
const studyMetadata = OHIF.viewerbase.getStudyMetadata(study);
|
||||
|
||||
data[measurementType].push({
|
||||
measurement,
|
||||
metadata: studyMetadata.getFirstInstance(),
|
||||
timepoint
|
||||
});
|
||||
});
|
||||
studyPromises.push(promise);
|
||||
});
|
||||
};
|
||||
|
||||
fillData('targets');
|
||||
fillData('nonTargets');
|
||||
|
||||
Promise.all(studyPromises).then(() => {
|
||||
resolve(data);
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
static setEvaluationDefinitions(evaluationKey, evaluationDefinitions) {
|
||||
evaluations[evaluationKey] = evaluationDefinitions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
OHIF.measurements.ConformanceCriteria = ConformanceCriteria;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { BaseCriterion } from './criteria/BaseCriterion';
|
||||
import * as Criteria from './criteria';
|
||||
import _ from 'underscore';
|
||||
import Ajv from 'ajv';
|
||||
|
||||
export class CriteriaEvaluator {
|
||||
|
||||
constructor(criteriaObject) {
|
||||
const criteriaValidator = this.getCriteriaValidator();
|
||||
this.criteria = [];
|
||||
|
||||
if (!criteriaValidator(criteriaObject)) {
|
||||
let message = '';
|
||||
_.each(criteriaValidator.errors, error => {
|
||||
message += `\noptions${error.dataPath} ${error.message}`;
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
_.each(criteriaObject, (optionsObject, criterionkey) => {
|
||||
const Criterion = Criteria[`${criterionkey}Criterion`];
|
||||
const optionsArray = optionsObject instanceof Array ? optionsObject : [optionsObject];
|
||||
_.each(optionsArray, options => this.criteria.push(new Criterion(options)));
|
||||
});
|
||||
}
|
||||
|
||||
getMaxTargets(newTarget=false) {
|
||||
let result = 0;
|
||||
_.each(this.criteria, criterion => {
|
||||
const newTargetMatch = newTarget === !!criterion.options.newTarget;
|
||||
if (criterion instanceof Criteria.MaxTargetsCriterion && newTargetMatch) {
|
||||
const { limit } = criterion.options;
|
||||
if (limit > result) {
|
||||
result = limit;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
getCriteriaValidator() {
|
||||
if (CriteriaEvaluator.criteriaValidator) {
|
||||
return CriteriaEvaluator.criteriaValidator;
|
||||
}
|
||||
|
||||
const schema = {
|
||||
properties: {},
|
||||
definitions: {}
|
||||
};
|
||||
|
||||
_.each(Criteria, (Criterion, key) => {
|
||||
if (Criterion.prototype instanceof BaseCriterion) {
|
||||
const criterionkey = key.replace(/Criterion$/, '');
|
||||
const criterionDefinition = `#/definitions/${criterionkey}`;
|
||||
|
||||
schema.definitions[criterionkey] = Criteria[`${criterionkey}Schema`];
|
||||
schema.properties[criterionkey] = {
|
||||
oneOf: [
|
||||
{ $ref: criterionDefinition },
|
||||
{
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: criterionDefinition
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
CriteriaEvaluator.criteriaValidator = new Ajv().compile(schema);
|
||||
return CriteriaEvaluator.criteriaValidator;
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const nonconformities = [];
|
||||
this.criteria.forEach(criterion => {
|
||||
const criterionResult = criterion.evaluate(data);
|
||||
if (!criterionResult.passed) {
|
||||
nonconformities.push(criterionResult);
|
||||
}
|
||||
});
|
||||
return nonconformities;
|
||||
}
|
||||
|
||||
static setCriterion(criterionKey, criterionDefinitions) {
|
||||
Criteria[criterionKey] = criterionDefinitions;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import _ from 'underscore';
|
||||
|
||||
export class BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
generateResponse(message, measurements) {
|
||||
const passed = !message;
|
||||
const isGlobal = !measurements || !measurements.length;
|
||||
|
||||
return {
|
||||
passed,
|
||||
isGlobal,
|
||||
message,
|
||||
measurements
|
||||
};
|
||||
}
|
||||
|
||||
getNewTargetNumbers(data) {
|
||||
const { options } = this;
|
||||
const baselineMeasurementNumbers = [];
|
||||
const newTargetNumbers = new Set();
|
||||
|
||||
if (options.newTarget) {
|
||||
_.each(data.targets, target => {
|
||||
const { measurementNumber } = target.measurement;
|
||||
if (target.timepoint.timepointType === 'baseline') {
|
||||
baselineMeasurementNumbers.push(measurementNumber);
|
||||
}
|
||||
});
|
||||
_.each(data.targets, target => {
|
||||
const { measurementNumber } = target.measurement;
|
||||
if (target.timepoint.timepointType === 'followup') {
|
||||
if (!_.contains(baselineMeasurementNumbers, measurementNumber)) {
|
||||
newTargetNumbers.add(measurementNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newTargetNumbers;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const LocationSchema = {
|
||||
type: 'object'
|
||||
};
|
||||
|
||||
/* LocationCriterion
|
||||
* Check if the there are non-target measurements with response different than "present" on baseline
|
||||
*/
|
||||
export class LocationCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const items = data.targets.concat(data.nonTargets);
|
||||
const measurements = [];
|
||||
let message;
|
||||
|
||||
items.forEach(item => {
|
||||
const measurement = item.measurement;
|
||||
|
||||
if (!measurement.location) {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
if (measurements.length) {
|
||||
message = 'All measurements should have a location';
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import _ from 'underscore';
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const MaxTargetsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
label: 'Max targets allowed in study',
|
||||
type: 'integer',
|
||||
minimum: 0
|
||||
},
|
||||
newTarget: {
|
||||
label: 'Flag to evaluate only new targets',
|
||||
type: 'boolean'
|
||||
},
|
||||
locationIn: {
|
||||
label: 'Filter to evaluate only measurements with the specified locations',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
locationNotIn: {
|
||||
label: 'Filter to evaluate only measurements without the specified locations',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
}
|
||||
},
|
||||
required: ['limit']
|
||||
};
|
||||
|
||||
/* MaxTargetsCriterion
|
||||
* Check if the number of target measurements exceeded the limit allowed
|
||||
* Options:
|
||||
* limit: Max targets allowed in study
|
||||
* newTarget: Flag to evaluate only new targets (must be evaluated on both)
|
||||
* locationIn: Filter to evaluate only measurements with the specified locations
|
||||
* locationNotIn: Filter to evaluate only measurements without the specified locations
|
||||
* message: Message to be displayed in case of nonconformity
|
||||
*/
|
||||
export class MaxTargetsCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const { options } = this;
|
||||
|
||||
const newTargetNumbers = this.getNewTargetNumbers(data);
|
||||
const measurementNumbers = [];
|
||||
_.each(data.targets, target => {
|
||||
const { location, measurementNumber, isSplitLesion } = target.measurement;
|
||||
if (isSplitLesion) return;
|
||||
if (options.newTarget && !newTargetNumbers.has(measurementNumber)) return;
|
||||
if (options.locationIn && options.locationIn.indexOf(location) === -1) return;
|
||||
if (options.locationNotIn && options.locationNotIn.indexOf(location) > -1) return;
|
||||
measurementNumbers.push(measurementNumber);
|
||||
});
|
||||
|
||||
let message;
|
||||
if (measurementNumbers.length > options.limit) {
|
||||
const increment = options.newTarget ? 'new ' : '';
|
||||
const plural = options.limit === 1 ? '' : 's';
|
||||
const amount = options.limit === 0 ? '' : `more than ${options.limit}`;
|
||||
message = options.message || `The study should not have ${amount} ${increment}target${plural}.`;
|
||||
}
|
||||
|
||||
return this.generateResponse(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import _ from 'underscore';
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const MaxTargetsPerOrganSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
label: 'Max targets allowed per organ',
|
||||
type: 'integer',
|
||||
minimum: 1
|
||||
},
|
||||
newTarget: {
|
||||
label: 'Flag to evaluate only new targets',
|
||||
type: 'boolean'
|
||||
}
|
||||
},
|
||||
required: ['limit']
|
||||
};
|
||||
|
||||
/*
|
||||
* MaxTargetsPerOrganCriterion
|
||||
* Check if the number of target measurements per organ exceeded the limit allowed
|
||||
* Options:
|
||||
* limit: Max targets allowed in study
|
||||
* newTarget: Flag to evaluate only new targets (must be evaluated on both)
|
||||
*/
|
||||
export class MaxTargetsPerOrganCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const { options } = this;
|
||||
const targetsPerOrgan = {};
|
||||
let measurements = [];
|
||||
|
||||
const newTargetNumbers = this.getNewTargetNumbers(data);
|
||||
_.each(data.targets, target => {
|
||||
const { measurement } = target;
|
||||
const { location, measurementNumber, isSplitLesion } = measurement;
|
||||
|
||||
if (isSplitLesion) return;
|
||||
|
||||
if (!targetsPerOrgan[location]) {
|
||||
targetsPerOrgan[location] = new Set();
|
||||
}
|
||||
|
||||
if (!options.newTarget || newTargetNumbers.has(measurementNumber)) {
|
||||
targetsPerOrgan[location].add(measurementNumber);
|
||||
}
|
||||
|
||||
if (targetsPerOrgan[location].size > options.limit) {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
let message;
|
||||
if (measurements.length) {
|
||||
const increment = options.newTarget ? 'new ' : '';
|
||||
message = options.message || `Each organ should not have more than ${options.limit} ${increment}targets.`;
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const MeasurementsLengthSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
longAxis: {
|
||||
label: 'Minimum length of long axis',
|
||||
type: 'number',
|
||||
minimum: 0
|
||||
},
|
||||
shortAxis: {
|
||||
label: 'Minimum length of short axis',
|
||||
type: 'number',
|
||||
minimum: 0
|
||||
},
|
||||
longAxisSliceThicknessMultiplier: {
|
||||
label: 'Length of long axis multiplier',
|
||||
type: 'number',
|
||||
minimum: 0
|
||||
},
|
||||
shortAxisSliceThicknessMultiplier: {
|
||||
label: 'Length of short axis multiplier',
|
||||
type: 'number',
|
||||
minimum: 0
|
||||
},
|
||||
modalityIn: {
|
||||
label: 'Filter to evaluate only measurements with the specified modalities',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
modalityNotIn: {
|
||||
label: 'Filter to evaluate only measurements without the specified modalities',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
locationIn: {
|
||||
label: 'Filter to evaluate only measurements with the specified locations',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
locationNotIn: {
|
||||
label: 'Filter to evaluate only measurements without the specified locations',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
message: {
|
||||
label: 'Message to be displayed in case of nonconformity',
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['message', 'longAxis'] },
|
||||
{ required: ['message', 'shortAxis'] },
|
||||
{ required: ['message', 'longAxisSliceThicknessMultiplier'] },
|
||||
{ required: ['message', 'shortAxisSliceThicknessMultiplier'] }
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* MeasurementsLengthCriterion
|
||||
* Check the measurements of all bidirectional tools based on
|
||||
* short axis, long axis, modalities, location and slice thickness
|
||||
* Options:
|
||||
* longAxis: Minimum length of long axis
|
||||
* shortAxis: Minimum length of short axis
|
||||
* longAxisSliceThicknessMultiplier: Length of long axis multiplier
|
||||
* shortAxisSliceThicknessMultiplier: Length of short axis multiplier
|
||||
* modalityIn: Filter to evaluate only measurements with the specified modalities
|
||||
* modalityNotIn: Filter to evaluate only measurements without the specified modalities
|
||||
* locationIn: Filter to evaluate only measurements with the specified locations
|
||||
* locationNotIn: Filter to evaluate only measurements without the specified locations
|
||||
* message: Message to be displayed in case of nonconformity
|
||||
*/
|
||||
export class MeasurementsLengthCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
let message;
|
||||
let measurements = [];
|
||||
const { options } = this;
|
||||
const longMultiplier = options.longAxisSliceThicknessMultiplier;
|
||||
const shortMultiplier = options.shortAxisSliceThicknessMultiplier;
|
||||
|
||||
data.targets.forEach(item => {
|
||||
const { metadata, measurement } = item;
|
||||
const { location } = measurement;
|
||||
|
||||
let { longestDiameter, shortestDiameter } = measurement;
|
||||
if (measurement.childToolsCount) {
|
||||
const child = measurement.bidirectional;
|
||||
longestDiameter = (child && child.longestDiameter) || 0;
|
||||
shortestDiameter = (child && child.shortestDiameter) || 0;
|
||||
}
|
||||
|
||||
const { sliceThickness } = metadata;
|
||||
const modality = (metadata.getRawValue('x00080060') || '').toUpperCase();
|
||||
|
||||
// Stop here if the measurement does not match the modality and location filters
|
||||
if (options.locationIn && options.locationIn.indexOf(location) === -1) return;
|
||||
if (options.modalityIn && options.modalityIn.indexOf(modality) === -1) return;
|
||||
if (options.locationNotIn && options.locationNotIn.indexOf(location) > -1) return;
|
||||
if (options.modalityNotIn && options.modalityNotIn.indexOf(modality) > -1) return;
|
||||
|
||||
// Check the measurement length
|
||||
const failed = (
|
||||
(options.longAxis && longestDiameter < options.longAxis) ||
|
||||
(options.shortAxis && shortestDiameter < options.shortAxis) || (
|
||||
longMultiplier && !isNaN(sliceThickness) &&
|
||||
longestDiameter < (longMultiplier * sliceThickness)
|
||||
) || (
|
||||
shortMultiplier && !isNaN(sliceThickness) &&
|
||||
shortestDiameter < (shortMultiplier * sliceThickness)
|
||||
)
|
||||
);
|
||||
|
||||
// Mark this measurement as invalid if some of the checks have failed
|
||||
if (failed) {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
// Use the options' message if some measurement is invalid
|
||||
if (measurements.length) {
|
||||
message = options.message;
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
import _ from 'underscore';
|
||||
|
||||
export const ModalitySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
method: {
|
||||
label: 'Specify if it\'s goinig to "allow" or "deny" the modalities',
|
||||
type: 'string',
|
||||
enum: ['allow', 'deny']
|
||||
},
|
||||
measurementTypes: {
|
||||
label: 'List of measurement types that will be evaluated',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
},
|
||||
modalities: {
|
||||
label: 'List of allowed/denied modalities',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
minItems: 1,
|
||||
uniqueItems: true
|
||||
}
|
||||
},
|
||||
required: ['method', 'modalities']
|
||||
};
|
||||
|
||||
/*
|
||||
* ModalityCriteria
|
||||
* Check if a modality is allowed or denied
|
||||
* Options:
|
||||
* method (string): Specify if it\'s goinig to "allow" or "deny" the modalities
|
||||
* measurementTypes (string[]): List of measurement types that will be evaluated
|
||||
* modalities (string[]): List of allowed/denied modalities
|
||||
*/
|
||||
export class ModalityCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const measurementTypes = this.options.measurementTypes || ['targets'];
|
||||
const modalitiesSet = new Set(this.options.modalities);
|
||||
const validationMethod = this.options.method;
|
||||
const measurements = [];
|
||||
const invalidModalities = [];
|
||||
let message;
|
||||
|
||||
measurementTypes.forEach(measurementType => {
|
||||
const items = data[measurementType];
|
||||
|
||||
items.forEach(item => {
|
||||
const { measurement, metadata } = item;
|
||||
const modality = (metadata.getRawValue('x00080060') || '').toUpperCase();
|
||||
|
||||
if (((validationMethod === 'allow') && !modalitiesSet.has(modality)) ||
|
||||
((validationMethod === 'deny') && modalitiesSet.has(modality))) {
|
||||
measurements.push(measurement);
|
||||
invalidModalities.push(modality);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (measurements.length) {
|
||||
const uniqueModalities = _.uniq(invalidModalities);
|
||||
const uniqueModalitiesText = uniqueModalities.join(', ');
|
||||
const modalityText = uniqueModalities.length > 1 ? 'modalities' : 'modality';
|
||||
|
||||
message = `The ${modalityText} ${uniqueModalitiesText} should not be used as a method of measurement`;
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const NonTargetResponseSchema = {
|
||||
type: 'object'
|
||||
};
|
||||
|
||||
/* NonTargetResponseCriterion
|
||||
* Check if the there are non-target measurements with response different than "present" on baseline
|
||||
*/
|
||||
export class NonTargetResponseCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const items = data.nonTargets;
|
||||
const measurements = [];
|
||||
let message;
|
||||
|
||||
items.forEach(item => {
|
||||
const measurement = item.measurement;
|
||||
const response = (measurement.response || '').toLowerCase();
|
||||
|
||||
if (response !== 'present') {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
if (measurements.length) {
|
||||
message = 'Non-targets can only be assessed as "present"';
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BaseCriterion } from './BaseCriterion';
|
||||
|
||||
export const TargetTypeSchema = {
|
||||
type: 'object'
|
||||
};
|
||||
|
||||
/* TargetTypeCriterion
|
||||
* Check if the there are non-bidirectional target measurements on baseline
|
||||
*/
|
||||
export class TargetTypeCriterion extends BaseCriterion {
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
evaluate(data) {
|
||||
const items = data.targets;
|
||||
const measurements = [];
|
||||
let message;
|
||||
|
||||
items.forEach(item => {
|
||||
const measurement = item.measurement;
|
||||
|
||||
if (measurement.toolType !== 'bidirectional' && !measurement.bidirectional) {
|
||||
measurements.push(measurement);
|
||||
}
|
||||
});
|
||||
|
||||
if (measurements.length) {
|
||||
message = 'Target lesions must have measurements (cannot be assessed as CR, UN/NE, EX)';
|
||||
}
|
||||
|
||||
return this.generateResponse(message, measurements);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './Location';
|
||||
export * from './MaxTargetsPerOrgan';
|
||||
export * from './MaxTargets';
|
||||
export * from './MeasurementsLength';
|
||||
export * from './Modality';
|
||||
export * from './NonTargetResponse';
|
||||
export * from './TargetType';
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as recistEvaluation from './recist.json';
|
||||
|
||||
export const recist = recistEvaluation;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"both": {
|
||||
"Location": {}
|
||||
},
|
||||
"baseline": {
|
||||
"TargetType": {},
|
||||
"MaxTargetsPerOrgan": {
|
||||
"limit": 2
|
||||
},
|
||||
"MaxTargets": {
|
||||
"limit": 5
|
||||
},
|
||||
"MeasurementsLength": [{
|
||||
"longAxis": 10,
|
||||
"longAxisSliceThicknessMultiplier": 2,
|
||||
"modalityIn": ["CT", "MR"],
|
||||
"locationNotIn": ["Lymph Node"],
|
||||
"message": "Extranodal lesions must be >= 10mm long axis AND >= double the acquisition slice thickness by CT and MR"
|
||||
}, {
|
||||
"shortAxis": 20,
|
||||
"longAxis": 20,
|
||||
"modalityIn": ["PX", "XA"],
|
||||
"locationNotIn": ["Lymph Node"],
|
||||
"message": "Extranodal lesions must be >= 20mm on chest x-ray (although x-rays rarely used for clinical trial assessment)"
|
||||
}, {
|
||||
"shortAxis": 15,
|
||||
"shortAxisSliceThicknessMultiplier": 2,
|
||||
"modalityIn": ["CT", "MR"],
|
||||
"locationIn": ["Lymph Node"],
|
||||
"message": "Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR"
|
||||
}]
|
||||
},
|
||||
"followup": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './ConformanceCriteria';
|
||||
@@ -0,0 +1 @@
|
||||
import './measurements.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
// Get the current measurement API configuration with information about tools, data exchange
|
||||
// and data validation.
|
||||
Template.registerHelper('measurementConfiguration', () => {
|
||||
return OHIF.measurements.MeasurementApi.getConfiguration();
|
||||
});
|
||||
|
||||
// Translates the location and return a string containing its label
|
||||
Template.registerHelper('getLocationLabel', label => {
|
||||
return OHIF.measurements.getLocationLabel(label);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import './base';
|
||||
import './conformance';
|
||||
import './lib';
|
||||
import './helpers';
|
||||
import './configuration'
|
||||
import './schema';
|
||||
@@ -0,0 +1,81 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import handleSingleMeasurementAdded from './handleSingleMeasurementAdded';
|
||||
import handleChildMeasurementAdded from './handleChildMeasurementAdded';
|
||||
import handleSingleMeasurementModified from './handleSingleMeasurementModified';
|
||||
import handleChildMeasurementModified from './handleChildMeasurementModified';
|
||||
import handleSingleMeasurementRemoved from './handleSingleMeasurementRemoved';
|
||||
import handleChildMeasurementRemoved from './handleChildMeasurementRemoved';
|
||||
|
||||
const MeasurementHandlers = {
|
||||
handleSingleMeasurementAdded,
|
||||
handleChildMeasurementAdded,
|
||||
handleSingleMeasurementModified,
|
||||
handleChildMeasurementModified,
|
||||
handleSingleMeasurementRemoved,
|
||||
handleChildMeasurementRemoved,
|
||||
|
||||
onAdded(event, instance) {
|
||||
const eventData = event.detail;
|
||||
const { toolType } = eventData;
|
||||
const { toolGroupId, toolGroup, tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
const params = {
|
||||
instance,
|
||||
eventData,
|
||||
tool,
|
||||
toolGroupId,
|
||||
toolGroup
|
||||
};
|
||||
|
||||
if (!tool) return;
|
||||
|
||||
if (tool.parentTool) {
|
||||
this.handleChildMeasurementAdded(params);
|
||||
} else {
|
||||
this.handleSingleMeasurementAdded(params);
|
||||
}
|
||||
},
|
||||
|
||||
onModified(event, instance) {
|
||||
const eventData = event.detail;
|
||||
const { toolType } = eventData;
|
||||
const { toolGroupId, toolGroup, tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
const params = {
|
||||
instance,
|
||||
eventData,
|
||||
tool,
|
||||
toolGroupId,
|
||||
toolGroup
|
||||
};
|
||||
|
||||
if (!tool) return;
|
||||
|
||||
if (tool.parentTool) {
|
||||
this.handleChildMeasurementModified(params);
|
||||
} else {
|
||||
this.handleSingleMeasurementModified(params);
|
||||
}
|
||||
},
|
||||
|
||||
onRemoved(e, instance) {
|
||||
const eventData = event.detail;
|
||||
const { toolType } = eventData;
|
||||
const { toolGroupId, toolGroup, tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
const params = {
|
||||
instance,
|
||||
eventData,
|
||||
tool,
|
||||
toolGroupId,
|
||||
toolGroup
|
||||
};
|
||||
|
||||
if (!tool) return;
|
||||
|
||||
if (tool.parentTool) {
|
||||
MeasurementHandlers.handleChildMeasurementRemoved(params);
|
||||
} else {
|
||||
MeasurementHandlers.handleSingleMeasurementRemoved(params);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
OHIF.measurements.MeasurementHandlers = MeasurementHandlers;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
export default function (element) {
|
||||
// Get the Cornerstone imageId
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
const imageId = enabledElement.image.imageId;
|
||||
|
||||
// Get studyInstanceUid & patientId
|
||||
const study = cornerstone.metaData.get('study', imageId);
|
||||
const studyInstanceUid = study.studyInstanceUid;
|
||||
const patientId = study.patientId;
|
||||
|
||||
// Get seriesInstanceUid
|
||||
const series = cornerstone.metaData.get('series', imageId);
|
||||
const seriesInstanceUid = series.seriesInstanceUid;
|
||||
|
||||
// Get sopInstanceUid
|
||||
const sopInstance = cornerstone.metaData.get('instance', imageId);
|
||||
const sopInstanceUid = sopInstance.sopInstanceUid;
|
||||
const frameIndex = sopInstance.frame || 0;
|
||||
|
||||
const imagePath = [studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex].join('_');
|
||||
|
||||
return {
|
||||
patientId,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
frameIndex,
|
||||
imagePath
|
||||
};
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
import getImageAttributes from './getImageAttributes';
|
||||
|
||||
export default function ({ instance, eventData, tool, toolGroupId, toolGroup }) {
|
||||
const { measurementApi } = instance.data;
|
||||
const { measurementData } = eventData;
|
||||
|
||||
const Collection = measurementApi.tools[tool.parentTool];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
// Stop here if there's no measurement data or if it was cancelled
|
||||
if (!measurementData || measurementData.cancelled) return;
|
||||
|
||||
OHIF.log.info('CornerstoneToolsMeasurementAdded');
|
||||
|
||||
const imageAttributes = getImageAttributes(eventData.element);
|
||||
const measurement = {
|
||||
toolType: tool.parentTool,
|
||||
measurementNumber: measurementData.measurementNumber,
|
||||
userId: OHIF.user.getUserId(),
|
||||
patientId: imageAttributes.patientId,
|
||||
studyInstanceUid: imageAttributes.studyInstanceUid
|
||||
};
|
||||
|
||||
const additionalProperties = _.extend(imageAttributes, {
|
||||
userId: OHIF.user.getUserId()
|
||||
});
|
||||
|
||||
const childMeasurement = _.extend({}, measurementData, additionalProperties);
|
||||
|
||||
const parentMeasurement = Collection.findOne({
|
||||
toolType: tool.parentTool,
|
||||
patientId: imageAttributes.patientId,
|
||||
[tool.attribute]: null
|
||||
});
|
||||
|
||||
// Check if a measurement to fit this child tool already exists
|
||||
if (parentMeasurement) {
|
||||
const key = tool.attribute;
|
||||
|
||||
// Add the createdAt attribute
|
||||
childMeasurement.createdAt = new Date();
|
||||
|
||||
// Add the child measurement
|
||||
measurement[key] = childMeasurement;
|
||||
|
||||
// Clean the measurement according to the Schema
|
||||
Collection._c2._simpleSchema.clean(measurement);
|
||||
|
||||
// Update the measurement in the collection
|
||||
Collection.update(parentMeasurement._id, {
|
||||
$set: { [key]: measurement[key] },
|
||||
$inc: { childToolsCount: 1 }
|
||||
});
|
||||
|
||||
// Update the measurementData ID and measurementNumber
|
||||
measurementData._id = parentMeasurement._id;
|
||||
measurementData.measurementNumber = parentMeasurement.measurementNumber;
|
||||
} else {
|
||||
measurement[tool.attribute] = _.extend({}, measurementData, additionalProperties);
|
||||
|
||||
// Get the related timepoint by the measurement number and use its location if defined
|
||||
const relatedTimepoint = Collection.findOne({
|
||||
measurementNumber: measurement.measurementNumber,
|
||||
toolType: tool.parentTool,
|
||||
patientId: imageAttributes.patientId
|
||||
});
|
||||
|
||||
// Use the related timepoint location if found and defined
|
||||
if (relatedTimepoint && relatedTimepoint.location) {
|
||||
measurement.location = relatedTimepoint.location;
|
||||
}
|
||||
|
||||
// Use the related timepoint description if found and defined
|
||||
if (relatedTimepoint && relatedTimepoint.description) {
|
||||
measurement.description = relatedTimepoint.description;
|
||||
}
|
||||
|
||||
// Clean the measurement according to the Schema
|
||||
Collection._c2._simpleSchema.clean(measurement);
|
||||
|
||||
// Insert the new measurement into the collection
|
||||
measurementData._id = Collection.insert(measurement);
|
||||
|
||||
// Get the updated measurement number after inserting
|
||||
Meteor.defer(() => {
|
||||
measurementData.measurementNumber = Collection.findOne(measurementData._id).measurementNumber;
|
||||
cornerstone.updateImage(OHIF.viewerbase.viewportUtils.getActiveViewportElement());
|
||||
});
|
||||
}
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
export default function ({ instance, eventData, tool, toolGroupId, toolGroup }) {
|
||||
const { measurementApi } = instance.data;
|
||||
const { measurementData } = eventData;
|
||||
|
||||
const Collection = measurementApi.tools[tool.parentTool];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
OHIF.log.info('CornerstoneToolsMeasurementModified');
|
||||
|
||||
const measurement = Collection.findOne(measurementData._id);
|
||||
const childMeasurement = measurement && measurement[tool.attribute];
|
||||
|
||||
// Stop here if the measurement is already deleted
|
||||
if (!childMeasurement) return;
|
||||
|
||||
// Update the collection data with the cornerstone measurement data
|
||||
const ignoredKeys = ['location', 'description', 'response'];
|
||||
Object.keys(measurementData).forEach(key => {
|
||||
if (_.contains(ignoredKeys, key)) return;
|
||||
childMeasurement[key] = measurementData[key];
|
||||
});
|
||||
|
||||
// If the measurement configuration includes a value for Viewport,
|
||||
// we will populate this with the Cornerstone Viewport
|
||||
if (Collection._c2._simpleSchema.schema(`${tool.attribute}.viewport`)) {
|
||||
childMeasurement.viewport = cornerstone.getViewport(eventData.element);
|
||||
}
|
||||
|
||||
// Update the measurement in the collection
|
||||
Collection.update(measurement._id, { $set: { [tool.attribute]: childMeasurement } });
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import _ from 'underscore';
|
||||
import $ from 'jquery';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
export default function ({ instance, eventData, tool, toolGroupId, toolGroup }) {
|
||||
OHIF.log.info('CornerstoneToolsMeasurementRemoved');
|
||||
const measurementData = eventData.measurementData;
|
||||
const { measurementApi, timepointApi } = instance.data;
|
||||
const Collection = measurementApi.tools[tool.parentTool];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
const measurement = Collection.findOne(measurementData._id);
|
||||
|
||||
// Stop here if the measurement is already gone or never existed
|
||||
if (!measurement) return;
|
||||
|
||||
if (measurement.childToolsCount === 1) {
|
||||
// Remove the measurement
|
||||
Collection.remove(measurement._id);
|
||||
|
||||
// Sync the new measurement data with cornerstone tools
|
||||
const baseline = timepointApi.baseline();
|
||||
measurementApi.sortMeasurements(baseline.timepointId);
|
||||
} else {
|
||||
// Update the measurement in the collection
|
||||
Collection.update(measurement._id, {
|
||||
$set: { [tool.attribute]: null },
|
||||
$inc: { childToolsCount: -1 }
|
||||
});
|
||||
}
|
||||
|
||||
// Repaint the images on all viewports without the removed measurements
|
||||
_.each($('.imageViewerViewport'), element => cornerstone.updateImage(element));
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
import getImageAttributes from './getImageAttributes';
|
||||
|
||||
export default function ({ instance, eventData, tool }) {
|
||||
const { measurementApi } = instance.data;
|
||||
const { measurementData, toolType } = eventData;
|
||||
|
||||
const Collection = measurementApi.tools[toolType];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
// Stop here if there's no measurement data or if it was cancelled
|
||||
if (!measurementData || measurementData.cancelled) return;
|
||||
|
||||
OHIF.log.info('CornerstoneToolsMeasurementAdded');
|
||||
|
||||
const imageAttributes = getImageAttributes(eventData.element);
|
||||
const measurement = _.extend({}, measurementData, imageAttributes, {
|
||||
measurementNumber: measurementData.measurementNumber,
|
||||
userId: OHIF.user.getUserId(),
|
||||
toolType
|
||||
});
|
||||
|
||||
// Get the related timepoint by the measurement number and use its location if defined
|
||||
const relatedTimepoint = Collection.findOne({
|
||||
measurementNumber: measurement.measurementNumber,
|
||||
toolType: measurementData.toolType,
|
||||
patientId: imageAttributes.patientId,
|
||||
});
|
||||
|
||||
// Use the related timepoint location if found and defined
|
||||
if (relatedTimepoint && relatedTimepoint.location) {
|
||||
measurement.location = relatedTimepoint.location;
|
||||
}
|
||||
|
||||
// Use the related timepoint description if found and defined
|
||||
if (relatedTimepoint && relatedTimepoint.description) {
|
||||
measurement.description = relatedTimepoint.description;
|
||||
}
|
||||
|
||||
// Clean the measurement according to the Schema
|
||||
Collection._c2._simpleSchema.clean(measurement);
|
||||
|
||||
// Insert the new measurement into the collection
|
||||
measurementData._id = Collection.insert(measurement);
|
||||
|
||||
// Get the updated measurement number after inserting
|
||||
Meteor.defer(() => {
|
||||
measurementData.measurementNumber = Collection.findOne(measurementData._id).measurementNumber;
|
||||
cornerstone.updateImage(OHIF.viewerbase.viewportUtils.getActiveViewportElement());
|
||||
});
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
export default function ({ instance, eventData, tool, toolGroupId, toolGroup }) {
|
||||
const { measurementApi } = instance.data;
|
||||
const { measurementData, toolType } = eventData;
|
||||
|
||||
const Collection = measurementApi.tools[toolType];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
OHIF.log.info('CornerstoneToolsMeasurementModified');
|
||||
|
||||
const measurement = Collection.findOne(measurementData._id);
|
||||
|
||||
// Stop here if the measurement is already deleted
|
||||
if (!measurement) return;
|
||||
|
||||
// Update the collection data with the cornerstone measurement data
|
||||
const ignoredKeys = ['location', 'description', 'response'];
|
||||
Object.keys(measurementData).forEach(key => {
|
||||
if (_.contains(ignoredKeys, key)) return;
|
||||
measurement[key] = measurementData[key];
|
||||
});
|
||||
|
||||
const measurementId = measurement._id;
|
||||
delete measurement._id;
|
||||
|
||||
// If the measurement configuration includes a value for Viewport,
|
||||
// we will populate this with the Cornerstone Viewport
|
||||
if (Collection._c2._simpleSchema.schema('viewport')) {
|
||||
measurement.viewport = cornerstone.getViewport(eventData.element);
|
||||
}
|
||||
|
||||
// Update the measurement in the collection
|
||||
Collection.update(measurementId, { $set: measurement });
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import _ from 'underscore';
|
||||
import $ from 'jquery';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
export default function({ instance, eventData, tool, toolGroupId, toolGroup }) {
|
||||
OHIF.log.info('CornerstoneToolsMeasurementRemoved');
|
||||
const measurementData = eventData.measurementData;
|
||||
const { measurementApi, timepointApi } = instance.data;
|
||||
const Collection = measurementApi.tools[eventData.toolType];
|
||||
|
||||
// Stop here if the tool data shall not be persisted (e.g. temp tools)
|
||||
if (!Collection) return;
|
||||
|
||||
const measurementTypeId = measurementApi.toolsGroupsMap[eventData.toolType];
|
||||
const measurement = Collection.findOne(measurementData._id);
|
||||
|
||||
// Stop here if the measurement is already gone or never existed
|
||||
if (!measurement) return;
|
||||
|
||||
// Remove all the measurements with the given type and number
|
||||
const { measurementNumber, timepointId } = measurement;
|
||||
measurementApi.deleteMeasurements(measurementTypeId, {
|
||||
measurementNumber,
|
||||
timepointId
|
||||
});
|
||||
|
||||
// Sync the new measurement data with cornerstone tools
|
||||
const baseline = timepointApi.baseline();
|
||||
measurementApi.sortMeasurements(baseline.timepointId);
|
||||
|
||||
// Repaint the images on all viewports without the removed measurements
|
||||
_.each($('.imageViewerViewport:not(.empty)'), element => cornerstone.updateImage(element));
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
if (tool.toolGroup !== 'temp') {
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges(eventData.toolType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import './MeasurementHandlers';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
class MeasurementManager {
|
||||
|
||||
/**
|
||||
* If the current Measurements Number already exists
|
||||
* for any other timepoint, returns lesion locationUID
|
||||
* @param measurementData
|
||||
* @returns {number} - Measurement location ID
|
||||
*/
|
||||
static getLocationIdIfMeasurementExists(measurementData, collection) {
|
||||
const measurement = collection.findOne({
|
||||
measurementNumber: measurementData.measurementNumber
|
||||
});
|
||||
|
||||
if (!measurement) {
|
||||
return;
|
||||
}
|
||||
|
||||
return measurement.locationId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
OHIF.measurements.MeasurementManager = MeasurementManager;
|
||||
@@ -0,0 +1,89 @@
|
||||
import $ from 'jquery';
|
||||
import { cornerstone, cornerstoneTools } from 'meteor/ohif:cornerstone';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
const scrollToIndex = cornerstoneTools.import('util/scrollToIndex');
|
||||
|
||||
/**
|
||||
* Activates a specific tool data instance and deactivates all other
|
||||
* target and non-target measurement data
|
||||
*
|
||||
* @param element
|
||||
* @param measurementData
|
||||
*/
|
||||
function activateTool(measurementData) {
|
||||
const toolType = measurementData.toolType;
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(measurementData.imagePath);
|
||||
const toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState();
|
||||
|
||||
const imageToolState = toolState[imageId];
|
||||
const toolData = imageToolState && imageToolState[toolType];
|
||||
if (!toolData || !toolData.data || !toolData.data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When a measurement is selected, it will be activated in Cornerstone's
|
||||
// tool data
|
||||
const tool = toolData.data.find(data => data._id === measurementData._id);
|
||||
if (tool) {
|
||||
tool.active = true;
|
||||
}
|
||||
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(toolState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the image of the correct image index
|
||||
* Activate the selected measurement on the switched image (color to be green)
|
||||
* Deactivate all other measurements on the switched image (color to be white)
|
||||
*/
|
||||
OHIF.measurements.activateMeasurements = (element, measurementData) => {
|
||||
OHIF.log.info('activateMeasurements');
|
||||
|
||||
// If Cornerstone Viewport information was stored while the measurement was created,
|
||||
// we should re-apply this data when activating the measurement.
|
||||
const viewport = cornerstone.getViewport(element);
|
||||
|
||||
// TODO: Make this an option somewhere? For now we only want to apply windowWidth and
|
||||
// windowCenter
|
||||
const viewportPropertiesToUpdate = ['voi'];
|
||||
|
||||
// Check to make sure we actually stored viewport data before trying to apply it
|
||||
if (measurementData.viewport) {
|
||||
|
||||
// For each property which is not undefined, update it's value from the stored
|
||||
// measurement data
|
||||
viewportPropertiesToUpdate.forEach(prop => {
|
||||
const storedPropertyValue = measurementData.viewport[prop];
|
||||
if (storedPropertyValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewport[prop] = storedPropertyValue;
|
||||
});
|
||||
|
||||
// Apply the updated viewport parameters to the element
|
||||
cornerstone.setViewport(element, viewport);
|
||||
}
|
||||
|
||||
// Activate the tool in the tool data
|
||||
activateTool(measurementData);
|
||||
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
const currentImageId = enabledElement.image.imageId;
|
||||
const toolData = cornerstoneTools.getToolState(element, 'stack');
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(measurementData.imagePath);
|
||||
const imageIdIndex = toolData.data[0].imageIds.indexOf(imageId);
|
||||
|
||||
// If we aren't currently displaying the image that this tool is on,
|
||||
// scroll to it now.
|
||||
if (currentImageId !== imageId) {
|
||||
scrollToIndex(element, imageIdIndex);
|
||||
}
|
||||
|
||||
const $element = $(element);
|
||||
if (!$element.find('canvas').length) return;
|
||||
|
||||
$element.trigger('ViewerMeasurementsActivated');
|
||||
cornerstone.updateImage(element);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstoneTools } from 'meteor/ohif:cornerstone';
|
||||
|
||||
OHIF.measurements.clearCornerstoneToolState = () => {
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState({});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { OHIF } from 'meteor/ohif:core'
|
||||
|
||||
/**
|
||||
* Sets all tool data entries value for 'active' to false
|
||||
* This is used to remove the active color on entire sets of tools
|
||||
*/
|
||||
OHIF.measurements.deactivateAllToolData = () => {
|
||||
const toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState()
|
||||
|
||||
Object.keys(toolState).forEach(imageId => {
|
||||
const toolData = toolState[imageId];
|
||||
|
||||
Object.keys(toolData).forEach(toolType => {
|
||||
const specificToolData = toolData[toolType]
|
||||
if (!specificToolData || !specificToolData.data || !specificToolData.data.length) {
|
||||
return
|
||||
}
|
||||
|
||||
specificToolData.data.forEach(data => {
|
||||
data.active = false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(toolState)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { MeasurementReport } from '../reports/measurement';
|
||||
|
||||
OHIF.measurements.exportPdf = (measurementApi, timepointApi) => {
|
||||
const currentTimepoint = timepointApi.current();
|
||||
const { timepointId } = currentTimepoint;
|
||||
const study = OHIF.viewer.Studies.findBy({
|
||||
studyInstanceUid: currentTimepoint.studyInstanceUids[0]
|
||||
});
|
||||
const report = new MeasurementReport({
|
||||
header: {
|
||||
trial: 'RECIST 1.1',
|
||||
patientName: OHIF.viewerbase.helpers.formatPN(study.patientName),
|
||||
mrn: study.patientId,
|
||||
timepoint: timepointApi.name(currentTimepoint)
|
||||
}
|
||||
});
|
||||
|
||||
const printMeasurement = (measurement, callback) => {
|
||||
OHIF.measurements.getImageDataUrl({ measurement }).then(imageDataUrl => {
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(measurement.imagePath);
|
||||
const series = cornerstone.metaData.get('series', imageId);
|
||||
const instance = cornerstone.metaData.get('instance', imageId);
|
||||
|
||||
let info = measurement.response;
|
||||
if (!info) {
|
||||
info = measurement.longestDiameter;
|
||||
if (measurement.shortestDiameter) {
|
||||
info += ` × ${measurement.shortestDiameter}`;
|
||||
}
|
||||
|
||||
info += ' mm';
|
||||
}
|
||||
|
||||
info += ` (S:${series.seriesNumber}, I:${instance.instanceNumber})`;
|
||||
|
||||
let type = measurementApi.toolsGroupsMap[measurement.toolType];
|
||||
type = type === 'targets' ? 'Target' : 'Non-target';
|
||||
|
||||
report.printMeasurement({
|
||||
type,
|
||||
number: measurement.measurementNumber,
|
||||
location: OHIF.measurements.getLocationLabel(measurement.location) || '',
|
||||
info,
|
||||
image: imageDataUrl
|
||||
});
|
||||
|
||||
processMeasurements(callback);
|
||||
});
|
||||
};
|
||||
|
||||
const processMeasurements = callback => {
|
||||
const current = iterator.next();
|
||||
if (current.done) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement = current.value;
|
||||
printMeasurement(measurement, callback);
|
||||
};
|
||||
|
||||
const targets = measurementApi.fetch('targets', { timepointId });
|
||||
const nonTargets = measurementApi.fetch('nonTargets', { timepointId });
|
||||
const measurements = targets.concat(nonTargets);
|
||||
const iterator = measurements[Symbol.iterator]();
|
||||
|
||||
processMeasurements(() => report.save('measurements.pdf'));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { getCSVMeasurementData } from '../reports/reportMeasurementDataToCSV';
|
||||
|
||||
const downloadCSV = (csvData, args = {}) => {
|
||||
let data, link;
|
||||
|
||||
const filename = args.filename || 'export.csv';
|
||||
|
||||
if (csvData == null) return;
|
||||
|
||||
if (!csvData.match(/^data:text\/csv/i)) {
|
||||
csvData = 'data:text/csv;charset=utf-8,' + csvData;
|
||||
}
|
||||
|
||||
data = encodeURI(csvData);
|
||||
link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', filename);
|
||||
link.click();
|
||||
};
|
||||
|
||||
OHIF.measurements.exportCSV = async (measurementApi, timepointApi) => {
|
||||
const csvData = await getCSVMeasurementData(measurementApi, timepointApi);
|
||||
downloadCSV(csvData);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements.findAndRenderDisplaySet = (displaySets, viewportIndex, studyInstanceUid, seriesInstanceUid, sopInstanceUid, renderedCallback) => {
|
||||
// Find the proper stack to display
|
||||
const stacksFromSeries = displaySets.filter(stack => stack.seriesInstanceUid === seriesInstanceUid);
|
||||
const stack = stacksFromSeries.find(stack => {
|
||||
const imageIndex = stack.images.findIndex(image => image.getSOPInstanceUID() === sopInstanceUid);
|
||||
return imageIndex > -1;
|
||||
});
|
||||
|
||||
// TODO: make this work for multi-frame instances
|
||||
const specificImageIndex = stack.images.findIndex(image => image.getSOPInstanceUID() === sopInstanceUid);
|
||||
|
||||
const displaySetData = {
|
||||
studyInstanceUid: studyInstanceUid,
|
||||
seriesInstanceUid: seriesInstanceUid,
|
||||
displaySetInstanceUid: stack.displaySetInstanceUid,
|
||||
currentImageIdIndex: specificImageIndex
|
||||
};
|
||||
|
||||
// Add a renderedCallback to activate the measurements once it's
|
||||
if (renderedCallback) {
|
||||
displaySetData.renderedCallback = renderedCallback;
|
||||
}
|
||||
|
||||
OHIF.viewerbase.layoutManager.rerenderViewportWithNewDisplaySet(viewportIndex, displaySetData);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Session } from 'meteor/session';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Extensible method to get the timepoint of the active viewport
|
||||
*
|
||||
* @returns {Object} - Timepoint data for the active viewport
|
||||
*/
|
||||
OHIF.measurements.getActiveTimepoint = () => {
|
||||
const activeViewportIndex = window.store.getState().viewports.activeViewport;
|
||||
const { studyInstanceUid } = OHIF.viewerbase.layoutManager.viewportData[activeViewportIndex];
|
||||
return OHIF.viewer.timepointApi.study(studyInstanceUid)[0];
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import $ from 'jquery';
|
||||
import { cornerstone, cornerstoneMath, cornerstoneTools } from 'meteor/ohif:cornerstone';
|
||||
|
||||
OHIF.measurements.getImageDataUrl = ({
|
||||
imageType='image/jpeg',
|
||||
quality=1,
|
||||
width=512,
|
||||
height=512,
|
||||
cacheImage=true,
|
||||
imagePath,
|
||||
measurement,
|
||||
alwaysVisibleText=true,
|
||||
viewport
|
||||
}) => {
|
||||
imagePath = imagePath || measurement.imagePath;
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(imagePath);
|
||||
|
||||
// Create a deep copy of the measurement to prevent changing its original properties
|
||||
if (measurement) {
|
||||
measurement = $.extend(true, {}, measurement);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const loadMethod = cacheImage ? 'loadAndCacheImage' : 'loadImage';
|
||||
cornerstone[loadMethod](imageId).then(image => {
|
||||
// Create a cornerstone enabled element to handle the image
|
||||
const enabledElement = createEnabledElement(width, height);
|
||||
const element = enabledElement.element;
|
||||
|
||||
// Display the image on cornerstone's canvas
|
||||
cornerstone.displayImage(element, image);
|
||||
|
||||
// Add the measurement state and enable the tool if a measurement was given
|
||||
if (measurement) {
|
||||
const state = Object.assign({}, measurement, { active: true });
|
||||
Object.keys(measurement.handles).forEach(handleKey => {
|
||||
const handle = Object.assign({}, state.handles[handleKey]);
|
||||
handle.selected = false;
|
||||
handle.active = false;
|
||||
handle.moving = false;
|
||||
state.handles[handleKey] = handle;
|
||||
});
|
||||
|
||||
cornerstoneTools.addToolState(element, measurement.toolType, state);
|
||||
cornerstoneTools.setToolEnabled(measurement.toolType);
|
||||
}
|
||||
|
||||
// Set the viewport voi if present
|
||||
if (viewport && viewport.voi) {
|
||||
const csViewport = cornerstone.getViewport(element);
|
||||
Object.assign(csViewport, { voi: viewport.voi });
|
||||
cornerstone.setViewport(element, csViewport);
|
||||
}
|
||||
|
||||
// Resolve the current promise giving the dataUrl as parameter
|
||||
const renderedCallback = () => {
|
||||
const dataUrl = enabledElement.canvas.toDataURL(imageType, quality);
|
||||
|
||||
// Disable the tool and clear the measurement state if a measurement was given
|
||||
if (measurement) {
|
||||
cornerstoneTools.setToolDisabled(measurement.toolType);
|
||||
cornerstoneTools.clearToolState(element, measurement.toolType);
|
||||
}
|
||||
|
||||
// Destroy the cornerstone enabled element, removing it from the DOM
|
||||
destroyEnabledElement(enabledElement);
|
||||
|
||||
// Resolve the promise with the image's data URL
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
// Wait for image rendering to get its data URL
|
||||
$(element).one('cornerstoneimagerendered', () => {
|
||||
if (measurement && alwaysVisibleText) {
|
||||
rearrangeTextBox(image, measurement, element).then(() => renderedCallback());
|
||||
} else {
|
||||
renderedCallback();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getPoint = (x, y) => {
|
||||
return {
|
||||
x,
|
||||
y
|
||||
};
|
||||
};
|
||||
|
||||
const lineRectangleIntersection = (line, rect) => {
|
||||
let intersection;
|
||||
|
||||
Object.keys(rect).forEach(side => {
|
||||
if (intersection) return;
|
||||
const rectSegment = rect[side];
|
||||
intersection = cornerstoneMath.lineSegment.intersectLine(line, rectSegment);
|
||||
});
|
||||
|
||||
return intersection;
|
||||
};
|
||||
|
||||
const rearrangeTextBox = (image, measurement, element) => new Promise((resolve, reject) => {
|
||||
const handles = measurement && measurement.handles;
|
||||
if (!handles) return resolve();
|
||||
const { textBox, start, end } = handles;
|
||||
if (!textBox || !textBox.boundingBox || !start || !end) return resolve();
|
||||
|
||||
// Build the dashed line segment
|
||||
let dashedLine = new cornerstoneMath.Line3();
|
||||
const maxX = Math.max(start.x, end.x);
|
||||
const minX = Math.min(start.x, end.x);
|
||||
const maxY = Math.max(start.y, end.y);
|
||||
const minY = Math.min(start.y, end.y);
|
||||
dashedLine.start = getPoint(minX + ((maxX - minX) / 2), minY + ((maxY - minY) / 2));
|
||||
dashedLine.end = getPoint(textBox.x, textBox.y);
|
||||
|
||||
// Build the bounding rectangle
|
||||
const x0 = (textBox.boundingBox.width / 2);
|
||||
const x1 = image.width - x0;
|
||||
const y0 = (textBox.boundingBox.height / 2);
|
||||
const y1 = image.height - y0;
|
||||
const topLeft = getPoint(x0, y0);
|
||||
const topRight = getPoint(x1, y0);
|
||||
const bottomLeft = getPoint(x0, y1);
|
||||
const bottomRight = getPoint(x1, y1);
|
||||
const boundingRect = {
|
||||
top: new cornerstoneMath.Line3(topLeft, topRight),
|
||||
left: new cornerstoneMath.Line3(topLeft, bottomLeft),
|
||||
right: new cornerstoneMath.Line3(topRight, bottomRight),
|
||||
bottom: new cornerstoneMath.Line3(bottomLeft, bottomRight)
|
||||
};
|
||||
|
||||
// Check if the measurement center is outside the bounding rectangle
|
||||
const imageCenter = getPoint(image.width / 2, image.height / 2);
|
||||
const imageCenterToMeasurement = new cornerstoneMath.Line3();
|
||||
imageCenterToMeasurement.start = imageCenter;
|
||||
imageCenterToMeasurement.end = dashedLine.start;
|
||||
if (lineRectangleIntersection(imageCenterToMeasurement, boundingRect)) {
|
||||
dashedLine = new cornerstoneMath.Line3(imageCenter, dashedLine.end);
|
||||
}
|
||||
|
||||
// Check if the text box is outside the image area
|
||||
const intersection = lineRectangleIntersection(dashedLine, boundingRect);
|
||||
if (intersection) {
|
||||
textBox.boundingBox.left = intersection.x - x0;
|
||||
textBox.boundingBox.top = intersection.y - y0;
|
||||
Object.assign(textBox, intersection);
|
||||
cornerstone.updateImage(element);
|
||||
$(element).one('cornerstoneimagerendered', () => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const createEnabledElement = (width, height) => {
|
||||
const $element = $('<div></div>').css({
|
||||
height,
|
||||
left: 0,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
visibility: 'hidden',
|
||||
width,
|
||||
'z-index': -1
|
||||
});
|
||||
|
||||
const element = $element[0];
|
||||
$element.appendTo(document.body);
|
||||
cornerstone.enable(element, { renderer: OHIF.cornerstone.renderer });
|
||||
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
enabledElement.toolStateManager = cornerstoneTools.newImageIdSpecificToolStateManager();
|
||||
|
||||
return enabledElement;
|
||||
};
|
||||
|
||||
const destroyEnabledElement = enabledElement => {
|
||||
cornerstone.disable(enabledElement.element);
|
||||
$(enabledElement.element).remove();
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
const segmentedTerminologyList = [{
|
||||
label:'Abdomen/Chest Wall',
|
||||
value:'Abdomen/Chest Wall',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Adrenal',
|
||||
value:'Adrenal',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Bladder',
|
||||
value:'Bladder',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Bone',
|
||||
value:'Bone',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Brain',
|
||||
value:'Brain',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Breast',
|
||||
value:'Breast',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Colon',
|
||||
value:'Colon',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Esophagus',
|
||||
value:'Esophagus',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Extremities',
|
||||
value:'Extremities',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Gallbladder',
|
||||
value:'Gallbladder',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Kidney',
|
||||
value:'Kidney',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Liver',
|
||||
value:'Liver',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Lung',
|
||||
value:'Lung',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Lymph Node',
|
||||
value:'Lymph Node',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Mediastinum/Hilum',
|
||||
value:'Mediastinum/Hilum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Muscle',
|
||||
value:'Muscle',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Neck',
|
||||
value:'Neck',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Other Soft Tissue',
|
||||
value:'Other Soft Tissue',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Ovary',
|
||||
value:'Ovary',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Pancreas',
|
||||
value:'Pancreas',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Pelvis',
|
||||
value:'Pelvis',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Peritoneum/Omentum',
|
||||
value:'Peritoneum/Omentum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Prostate',
|
||||
value:'Prostate',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Retroperitoneum',
|
||||
value:'Retroperitoneum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Small Bowel',
|
||||
value:'Small Bowel',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Spleen',
|
||||
value:'Spleen',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Stomach',
|
||||
value:'Stomach',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Subcutaneous',
|
||||
value:'Subcutaneous',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
}
|
||||
];
|
||||
|
||||
const segmentedTerminologyCommonList = [{
|
||||
label:'Abdomen/Chest Wall',
|
||||
value:'Abdomen/Chest Wall',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Liver',
|
||||
value:'Liver',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Lung',
|
||||
value:'Lung',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Lymph Node',
|
||||
value:'Lymph Node',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Mediastinum/Hilum',
|
||||
value:'Mediastinum/Hilum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Pelvis',
|
||||
value:'Pelvis',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Peritoneum/Omentum',
|
||||
value:'Peritoneum/Omentum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
},{
|
||||
label:'Retroperitoneum',
|
||||
value:'Retroperitoneum',
|
||||
segmentedPropCategory: 'T-D0080'
|
||||
}
|
||||
];
|
||||
|
||||
export {
|
||||
segmentedTerminologyList,
|
||||
segmentedTerminologyCommonList
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Extensible method to translate the location and return a string containing its label
|
||||
*
|
||||
* @param location
|
||||
* @returns string - label for the given location
|
||||
*/
|
||||
OHIF.measurements.getLocationLabel = location => {
|
||||
return location;
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import _ from 'underscore';
|
||||
|
||||
// TODO: change this to a const after refactoring newMeasurements code on measurementTableView.js
|
||||
OHIF.measurements.getLocation = collection => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
if (collection[i].location) {
|
||||
return collection[i].location;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: change this to a const after refactoring newMeasurements code on measurementTableView.js
|
||||
OHIF.measurements.getDescription = collection => {
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
if (collection[i].description) {
|
||||
return collection[i].description;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Group all measurements by its tool group and measurement number.
|
||||
*
|
||||
* @param measurementApi
|
||||
* @param timepointApi
|
||||
* @returns {*} A list containing each toolGroup and an array containing the measurement rows
|
||||
*/
|
||||
OHIF.measurements.getMeasurementsGroupedByNumber = (measurementApi, timepointApi) => {
|
||||
const getPath = OHIF.utils.ObjectPath.get;
|
||||
const configuration = OHIF.measurements.MeasurementApi.getConfiguration();
|
||||
|
||||
if (!measurementApi || !timepointApi || !configuration) return;
|
||||
|
||||
// Check which tools are going to be displayed
|
||||
const displayToolGroupMap = {};
|
||||
const displayToolList = [];
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
displayToolGroupMap[toolGroup.id] = false;
|
||||
toolGroup.childTools.forEach(tool => {
|
||||
const willDisplay = !!getPath(tool, 'options.measurementTable.displayFunction');
|
||||
if (willDisplay) {
|
||||
displayToolList.push(tool.id);
|
||||
displayToolGroupMap[toolGroup.id] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Create the result object
|
||||
const groupedMeasurements = [];
|
||||
|
||||
const baseline = timepointApi.baseline();
|
||||
if (!baseline) return;
|
||||
|
||||
configuration.measurementTools.forEach(toolGroup => {
|
||||
// Skip this tool group if it should not be displayed
|
||||
if (!displayToolGroupMap[toolGroup.id]) return;
|
||||
|
||||
// Retrieve all the data for this Measurement type (e.g. 'targets')
|
||||
// which was recorded at baseline.
|
||||
const atBaseline = measurementApi.fetch(toolGroup.id, {
|
||||
timepointId: baseline.timepointId
|
||||
});
|
||||
|
||||
// Obtain a list of the Measurement Numbers from the
|
||||
// measurements which have baseline data
|
||||
const numbers = atBaseline.map(m => m.measurementNumber);
|
||||
|
||||
// Retrieve all the data for this Measurement type which
|
||||
// match the Measurement Numbers obtained above
|
||||
const data = measurementApi.fetch(toolGroup.id, {
|
||||
toolId: {
|
||||
$in: displayToolList
|
||||
},
|
||||
measurementNumber: {
|
||||
$in: numbers
|
||||
}
|
||||
});
|
||||
|
||||
// Group the Measurements by Measurement Number
|
||||
const groupObject = _.groupBy(data, entry => entry.measurementNumber);
|
||||
|
||||
// Reformat the data for display in the table
|
||||
const measurementRows = Object.keys(groupObject).map(key => ({
|
||||
measurementTypeId: toolGroup.id,
|
||||
measurementNumber: key,
|
||||
location: OHIF.measurements.getLocation(groupObject[key]),
|
||||
description: OHIF.measurements.getDescription(groupObject[key]),
|
||||
responseStatus: false, // TODO: Get the latest timepoint and determine the response status
|
||||
entries: groupObject[key]
|
||||
}));
|
||||
|
||||
// Add the group to the result
|
||||
groupedMeasurements.push({
|
||||
toolGroup,
|
||||
measurementRows
|
||||
});
|
||||
});
|
||||
|
||||
return groupedMeasurements;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Return the parent tool data if it's a child tool or the tool data itself if not
|
||||
*
|
||||
* @param measurementData measurement data that must contain the toolType and measurement's _id
|
||||
* @returns {Object} Parent measurement data
|
||||
*/
|
||||
OHIF.measurements.getParentToolData = measurementData => {
|
||||
const { toolType, _id } = measurementData;
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
const parentToolType = tool.parentTool || toolType;
|
||||
const parentToolData = OHIF.viewer.measurementApi.tools[parentToolType].findOne(_id);
|
||||
return parentToolData;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
OHIF.measurements.getTimepointName = timepoint => {
|
||||
// Check if this is a Baseline timepoint, if it is, return 'Baseline'
|
||||
if (timepoint.timepointType === 'baseline') {
|
||||
return 'Baseline';
|
||||
} else if (timepoint.visitNumber) {
|
||||
return 'Follow-up ' + timepoint.visitNumber;
|
||||
}
|
||||
|
||||
// Retrieve all of the relevant follow-up timepoints for this patient
|
||||
const 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
|
||||
const followupTimepointIds = followupTimepoints.map(timepoint => timepoint.timepointId);
|
||||
|
||||
// Calculate the index of the current timepoint in the array of all
|
||||
// relevant follow-up timepoints
|
||||
const 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) {
|
||||
OHIF.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;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Return the tool configuration of a given tool type
|
||||
*
|
||||
* @param {String} toolType The tool type of the desired configuration
|
||||
*/
|
||||
OHIF.measurements.getToolConfiguration = toolType => {
|
||||
const { MeasurementApi } = OHIF.measurements;
|
||||
const configuration = MeasurementApi.getConfiguration();
|
||||
const toolsGroupsMap = MeasurementApi.getToolsGroupsMap();
|
||||
|
||||
const toolGroupId = toolsGroupsMap[toolType];
|
||||
const toolGroup = _.findWhere(configuration.measurementTools, { id: toolGroupId });
|
||||
|
||||
let tool;
|
||||
if (toolGroup) {
|
||||
tool = _.findWhere(toolGroup.childTools, { id: toolType });
|
||||
}
|
||||
|
||||
return {
|
||||
toolGroupId,
|
||||
toolGroup,
|
||||
tool
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Viewerbase } from 'meteor/ohif:viewerbase';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
const { InstanceMetadata, StudySummary } = Viewerbase.metadata;
|
||||
|
||||
// TODO: [LT-refactor] move this to ohif:hanging-protocols package
|
||||
/**
|
||||
* Get a timepoint type for a given study metadata
|
||||
* @param {StudyMetadata} study StudyMetadata instance
|
||||
* @return {String|undefined} Timepoint type if found or undefined if not found or any error/missing information
|
||||
*/
|
||||
const getTimepointType = study => {
|
||||
const { timepointApi } = OHIF.viewer;
|
||||
|
||||
if (!timepointApi || !(study instanceof InstanceMetadata || study instanceof StudySummary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepoint = timepointApi.study(study.getStudyInstanceUID());
|
||||
if (!timepoint || !(timepoint instanceof Array) || timepoint.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return timepoint[0].timepointType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the timpoint key (prior/current/baseline) for a given study metadata
|
||||
* @param {StudyMetadata} study StudyMetadata instance
|
||||
* @return {String|undefined} Timepoint key if found or undefined if not found or any error/missing information
|
||||
*/
|
||||
const getTimepointKey = study => {
|
||||
const { timepointApi } = OHIF.viewer;
|
||||
|
||||
if (!timepointApi || !(study instanceof InstanceMetadata || study instanceof StudySummary)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepoint = timepointApi.study(study.getStudyInstanceUID());
|
||||
if (!timepoint || !(timepoint instanceof Array) || timepoint.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timepointId = timepoint[0]._id;
|
||||
if (timepointApi.current()._id === timepointId) {
|
||||
return 'current';
|
||||
} else if (timepointApi.prior()._id === timepointId) {
|
||||
return 'prior';
|
||||
} else if (timepointApi.baseline()._id === timepointId) {
|
||||
return 'baseline';
|
||||
}
|
||||
};
|
||||
|
||||
Meteor.startup(() => {
|
||||
HP = HP || false;
|
||||
|
||||
if (HP) {
|
||||
HP.addCustomAttribute('timepointType', 'Timepoint Type', getTimepointType);
|
||||
HP.addCustomAttribute('timepointKey', 'Timepoint Key', getTimepointKey);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import './jumpToRowItem';
|
||||
//import './activateMeasurements';
|
||||
import './clearCornerstoneToolState';
|
||||
import './deactivateAllToolData';
|
||||
import './exportPdf';
|
||||
import './exportToCsv';
|
||||
import './findAndRenderDisplaySet';
|
||||
import './getActiveTimepoint';
|
||||
import './getImageDataUrl';
|
||||
import './getMeasurementsGroupedByNumber';
|
||||
import './getLocationLabel';
|
||||
import './getParentToolData';
|
||||
import './getTimepointName';
|
||||
import './getToolConfiguration';
|
||||
import './hangingProtocolCustomizations';
|
||||
import './isNewLesionsMeasurement';
|
||||
import './isSaveDisabled';
|
||||
import './MeasurementHandlers';
|
||||
import './MeasurementManager';
|
||||
import './navigateOverLesions';
|
||||
import './saveMeasurements';
|
||||
import './syncMeasurementAndToolData';
|
||||
import './toggleLabelButton';
|
||||
import './openLocationModal';
|
||||
import './triggerTimepointUnsavedChanges';
|
||||
import './updateMeasurementsDescription';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import _ from 'underscore';
|
||||
|
||||
/**
|
||||
* Check if the given measurement is a new lesion
|
||||
*
|
||||
* @param measurementData Measurement that will be checked
|
||||
* @returns {Boolean} Boolean value telling if the given measurement is a new lesion or not
|
||||
*/
|
||||
OHIF.measurements.isNewLesionsMeasurement = measurementData => {
|
||||
if (!measurementData) return;
|
||||
|
||||
const toolConfig = OHIF.measurements.getToolConfiguration(measurementData.toolType);
|
||||
const toolType = toolConfig.tool.parentTool || measurementData.toolType;
|
||||
const { timepointApi, measurementApi } = OHIF.viewer;
|
||||
const currentMeasurement = measurementApi.tools[toolType].findOne(measurementData._id);
|
||||
const { timepointId, measurementNumber } = currentMeasurement;
|
||||
|
||||
// Stop here if the needed information is not set
|
||||
if (!measurementApi || !timepointApi || !timepointId || !toolConfig) return;
|
||||
|
||||
const { toolGroupId } = toolConfig;
|
||||
const current = timepointApi.timepoints.findOne({ timepointId });
|
||||
const baseline = timepointApi.baseline();
|
||||
|
||||
// Stop here if there's no current or baseline timepoints, or if the current is the baseline
|
||||
if (!current || !baseline || current.timepointType === 'baseline') return false;
|
||||
|
||||
// Retrieve all the data for the given tool group (e.g. 'targets')
|
||||
const atBaseline = measurementApi.fetch(toolGroupId, { timepointId: baseline.timepointId });
|
||||
|
||||
// Obtain a list of the Measurement Numbers from the measurements which have baseline data
|
||||
const numbers = atBaseline.map(m => m.measurementNumber);
|
||||
|
||||
// Return true if the measurement number from follow-up is not present at baseline
|
||||
return !_.contains(numbers, measurementNumber);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements.isSaveDisabled = timepointId => {
|
||||
const basePath = `viewer.studyViewer.measurements.${timepointId}`;
|
||||
|
||||
// Get the timepoint object
|
||||
const timepoint = OHIF.viewer.timepointApi.timepoints.findOne({ timepointId });
|
||||
|
||||
// Check if the timepoint is locked
|
||||
let isLocked = (timepoint && timepoint.isLocked);
|
||||
if (typeof isLocked === 'undefined') {
|
||||
isLocked = true;
|
||||
}
|
||||
|
||||
// Check if the given timepoint suffered changes
|
||||
const hasChanges = OHIF.ui.unsavedChanges.probe(basePath) !== 0;
|
||||
|
||||
// Check if the given timepoint has nonconformities
|
||||
const nonconformities = OHIF.viewer.conformanceCriteria.nonconformities.get();
|
||||
const hasNonconformities = nonconformities && !!nonconformities.length;
|
||||
|
||||
// Prevent saving if timepoint is locked, has no changes or has nonconformities
|
||||
return isLocked || !hasChanges || hasNonconformities;
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import $ from 'jquery';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstone } from 'meteor/ohif:cornerstone';
|
||||
|
||||
// Flag that can be changed to disable automatic stack scroll linking when jumping over lesions
|
||||
OHIF.measurements.automaticStackScrollLinkingEnabled = true;
|
||||
|
||||
function renderIntoViewport(measurementData, enabledElement, viewportIndex) {
|
||||
const { activateMeasurements, findAndRenderDisplaySet } = OHIF.measurements;
|
||||
const { element } = enabledElement;
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = measurementData;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const renderedCallback = element => {
|
||||
activateMeasurements(element, measurementData);
|
||||
$(element).one('cornerstoneimagerendered', () => resolve());
|
||||
};
|
||||
|
||||
// Find the study by studyInstanceUid and render the display set
|
||||
const findAndRender = () => {
|
||||
// @TypeSafeStudies
|
||||
const study = OHIF.viewer.Studies.findBy({ studyInstanceUid });
|
||||
|
||||
// TODO: Support frames? e.g. for measurements on multi-frame instances
|
||||
findAndRenderDisplaySet(
|
||||
study.displaySets,
|
||||
viewportIndex,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
renderedCallback
|
||||
);
|
||||
};
|
||||
|
||||
// Check if the study / series we need is already the one in the viewport.
|
||||
// Otherwise, re-render the viewport with the required study/series, then add a rendered
|
||||
// callback to activate the measurements
|
||||
if (enabledElement && enabledElement.image) {
|
||||
const imageId = enabledElement.image.imageId;
|
||||
const series = cornerstone.metaData.get('series', imageId);
|
||||
const study = cornerstone.metaData.get('study', imageId);
|
||||
|
||||
const isSameStudy = study.studyInstanceUid === measurementData.studyInstanceUid;
|
||||
const isSameSeries = series.seriesInstanceUid === measurementData.seriesInstanceUid;
|
||||
if (isSameStudy && isSameSeries) {
|
||||
// If it is, activate the measurements in this viewport and stop here
|
||||
OHIF.viewerbase.viewportUtils.resetViewport(viewportIndex);
|
||||
renderedCallback(element);
|
||||
} else {
|
||||
findAndRender();
|
||||
}
|
||||
} else {
|
||||
findAndRender();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncViewports(viewportsIndexes) {
|
||||
// Prevent stack scrolling from being linked if it's disabled
|
||||
if (!OHIF.measurements.automaticStackScrollLinkingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const synchronizer = OHIF.viewer.stackImagePositionOffsetSynchronizer;
|
||||
|
||||
if(!synchronizer) { return; }
|
||||
|
||||
const linkableViewports = synchronizer.getLinkableViewports();
|
||||
if (linkableViewports.length) {
|
||||
const linkableViewportsIndexes = _.pluck(linkableViewports, 'index');
|
||||
const indexes = _.intersection(linkableViewportsIndexes, viewportsIndexes);
|
||||
if (indexes.length) {
|
||||
OHIF.viewer.stackImagePositionOffsetSynchronizer.activateByViewportIndexes(indexes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store the lastActivatedRowItem to cancel jumping if another rowItem was triggered during loading
|
||||
let lastActivatedRowItem;
|
||||
|
||||
/**
|
||||
* Activates a set of lesions when lesion table row is clicked
|
||||
*
|
||||
* @param measurementId The unique key for a specific Measurement
|
||||
*/
|
||||
OHIF.measurements.jumpToRowItem = (rowItem, timepoints, childToolKey) => {
|
||||
const { isZoomed, zoomedViewportIndex } = OHIF.viewerbase.layoutManager;
|
||||
|
||||
lastActivatedRowItem = rowItem;
|
||||
|
||||
// Retrieve the list of available viewports
|
||||
const $viewports = $('.imageViewerViewport');
|
||||
const numViewports = Math.max($viewports.length, 0);
|
||||
|
||||
// Clone the timepoint list to prevent modifying the original object
|
||||
let timepointList;
|
||||
if (isZoomed) {
|
||||
timepointList = [timepoints[zoomedViewportIndex]];
|
||||
} else {
|
||||
timepointList = _.clone(timepoints);
|
||||
}
|
||||
|
||||
// Reverse the timepointList array if the flag is set
|
||||
if (OHIF.viewer.invertViewportTimepointsOrder) {
|
||||
timepointList.reverse();
|
||||
}
|
||||
|
||||
// Retrieve the timepoints that are currently being displayed in the Measurement Table
|
||||
const numTimepoints = Math.max(timepointList.length, 1);
|
||||
|
||||
const numViewportsToUpdate = Math.min(numTimepoints, numViewports);
|
||||
|
||||
// Retrieve the measurements data
|
||||
const measurementsData = [];
|
||||
const promises = new Set();
|
||||
for (let i = 0; i < numViewportsToUpdate; i++) {
|
||||
const { timepointId } = timepointList[i];
|
||||
|
||||
const dataAtThisTimepoint = _.where(rowItem.entries, { timepointId });
|
||||
if (!dataAtThisTimepoint || !dataAtThisTimepoint.length) {
|
||||
measurementsData.push(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const measurement = dataAtThisTimepoint[0];
|
||||
let measurementData = measurement;
|
||||
const { toolType } = measurementData;
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
if (childToolKey) {
|
||||
measurementData = measurementData[childToolKey];
|
||||
} else if (Array.isArray(tool.childTools)) {
|
||||
tool.childTools.every(key => {
|
||||
measurementData = measurementData[key];
|
||||
return !measurementData;
|
||||
});
|
||||
}
|
||||
|
||||
measurementsData.push(measurementData);
|
||||
const promise = OHIF.studies.loadStudy(measurementData.studyInstanceUid);
|
||||
promise.then(() => OHIF.measurements.syncMeasurementAndToolData(measurement));
|
||||
promises.add(promise);
|
||||
}
|
||||
|
||||
// Wait for studies metadata to be retrieved before jumpint to the given row item
|
||||
Promise.all(promises).then(() => {
|
||||
// Stop here if another rowItem was activated during loading process
|
||||
if (rowItem !== lastActivatedRowItem) return;
|
||||
|
||||
OHIF.measurements.deactivateAllToolData();
|
||||
|
||||
const activatedViewportIndexes = [];
|
||||
|
||||
// Deactivate stack synchronizer because it will be re-activated later
|
||||
const synchronizer = OHIF.viewer.stackImagePositionOffsetSynchronizer;
|
||||
if(synchronizer) {
|
||||
synchronizer.deactivate();
|
||||
}
|
||||
|
||||
const renderPromises = [];
|
||||
for (let viewportIndex = 0; viewportIndex < numViewportsToUpdate; viewportIndex++) {
|
||||
const measurementData = measurementsData[viewportIndex];
|
||||
if (!measurementData) continue;
|
||||
|
||||
activatedViewportIndexes.push(viewportIndex);
|
||||
|
||||
const element = $viewports.get(viewportIndex);
|
||||
|
||||
// TODO: Implement isEnabledElement in Cornerstone
|
||||
// or maybe just remove the 'error' this throws?
|
||||
let enabledElement;
|
||||
try {
|
||||
enabledElement = cornerstone.getEnabledElement(element);
|
||||
} catch(error) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const promise = renderIntoViewport(measurementData, enabledElement, viewportIndex);
|
||||
renderPromises.push(promise);
|
||||
}
|
||||
|
||||
// Wait for all viewports to be rendered then sync them
|
||||
Promise.all(renderPromises).then(() => syncViewports(activatedViewportIndexes));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Method to go select the next or previous lesion on measurements table
|
||||
*
|
||||
* @param {Boolean} isNextLesion Determine if it will navigate to the next or previous lesion
|
||||
*/
|
||||
OHIF.measurements.navigateOverLesions = isNextLesion => {
|
||||
const $table = $('#measurementTableContainer');
|
||||
if (!$table.length) return;
|
||||
|
||||
const $lesions = $table.find('.measurementTableRow');
|
||||
if (!$lesions.length) return;
|
||||
|
||||
const $activeLesion = $lesions.filter('.active');
|
||||
const activeIndex = $lesions.index($activeLesion);
|
||||
|
||||
const step = isNextLesion ? 1 : -1;
|
||||
let newIndex = 0;
|
||||
if (activeIndex !== -1) {
|
||||
newIndex = activeIndex + step;
|
||||
if (newIndex >= $lesions.length) {
|
||||
newIndex = 0;
|
||||
} else if (newIndex < 0) {
|
||||
newIndex = $lesions.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
$lesions.eq(newIndex).find('.measurementRowSidebar').trigger('click');
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements.openLocationModal = options => {
|
||||
let { toolType } = options.measurement;
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
|
||||
if (!tool) return;
|
||||
|
||||
toolType = (tool && tool.parentTool) || toolType;
|
||||
|
||||
const measurementId = options.measurement._id;
|
||||
let buttonView = null;
|
||||
|
||||
const removeButtonView = () => {
|
||||
Blaze.remove(buttonView);
|
||||
buttonView = null;
|
||||
};
|
||||
|
||||
if (buttonView) {
|
||||
removeButtonView();
|
||||
}
|
||||
|
||||
const measurementApi = options.measurementApi;
|
||||
const toolCollection = measurementApi.tools[toolType];
|
||||
const measurement = toolCollection.findOne(measurementId);
|
||||
|
||||
const data = {
|
||||
measurement,
|
||||
position: options.position,
|
||||
direction: options.direction,
|
||||
threeColumns: true,
|
||||
hideCommon: true,
|
||||
autoClick: options.autoClick,
|
||||
doneCallback: removeButtonView,
|
||||
updateCallback(location) {
|
||||
const groupId = measurementApi.toolsGroupsMap[toolType];
|
||||
const config = OHIF.measurements.MeasurementApi.getConfiguration();
|
||||
const group = _.findWhere(config.measurementTools, { id: groupId });
|
||||
group.childTools.forEach(tool => {
|
||||
measurementApi.tools[tool.id].update({
|
||||
measurementNumber: measurement.measurementNumber,
|
||||
patientId: measurement.patientId
|
||||
}, {
|
||||
$set: {
|
||||
location
|
||||
}
|
||||
}, {
|
||||
multi: true
|
||||
});
|
||||
});
|
||||
options.measurement.location = location;
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges('relabel');
|
||||
}
|
||||
};
|
||||
buttonView = Blaze.renderWithData(Template.measurementRelabel, data, document.body);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements.saveMeasurements = (measurementApi, timepointId) => {
|
||||
const { unsavedChanges, notifications, showDialog } = OHIF.ui;
|
||||
const basePath = `viewer.studyViewer.measurements.${timepointId}`;
|
||||
|
||||
// Prevent saving if it's disabled
|
||||
if (OHIF.measurements.isSaveDisabled(timepointId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear unsaved changes state and display success message
|
||||
const successHandler = () => {
|
||||
unsavedChanges.clear(basePath, true);
|
||||
notifications.success({ text: 'The measurement data was successfully saved' });
|
||||
};
|
||||
|
||||
// Display the error messages
|
||||
const errorHandler = data => {
|
||||
showDialog('dialogInfo', Object.assign({ class: 'themed' }, data));
|
||||
};
|
||||
|
||||
// Call the storage method and display a loading overlay
|
||||
const promise = measurementApi.storeMeasurements(timepointId);
|
||||
promise.then(successHandler).catch(errorHandler);
|
||||
showDialog('dialogLoading', {
|
||||
promise,
|
||||
text: 'Saving measurement data'
|
||||
});
|
||||
|
||||
// Return the save promise
|
||||
return promise;
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { cornerstoneTools } from 'meteor/ohif:cornerstone';
|
||||
|
||||
OHIF.measurements.syncMeasurementAndToolData = measurement => {
|
||||
OHIF.log.info('syncMeasurementAndToolData');
|
||||
|
||||
const toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState();
|
||||
|
||||
// Stop here if the metadata for the measurement's study is not loaded yet
|
||||
const { studyInstanceUid } = measurement;
|
||||
const metadata = OHIF.viewer.StudyMetadataList.findBy({ "studyInstanceUID": studyInstanceUid });
|
||||
if (!metadata) return;
|
||||
|
||||
// Iterate each child tool if the current tool has children
|
||||
const { getImageIdForImagePath } = OHIF.viewerbase;
|
||||
const toolType = measurement.toolType;
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
if (Array.isArray(tool.childTools)) {
|
||||
tool.childTools.forEach(childToolKey => {
|
||||
const childMeasurement = measurement[childToolKey];
|
||||
if (!childMeasurement) return;
|
||||
childMeasurement._id = measurement._id;
|
||||
childMeasurement.measurementNumber = measurement.measurementNumber;
|
||||
|
||||
OHIF.measurements.syncMeasurementAndToolData(childMeasurement);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const imageId = getImageIdForImagePath(measurement.imagePath);
|
||||
|
||||
// If no tool state exists for this imageId, create an empty object to store it
|
||||
if (!toolState[imageId]) {
|
||||
toolState[imageId] = {};
|
||||
}
|
||||
|
||||
const currentToolState = toolState[imageId][toolType];
|
||||
const toolData = currentToolState && currentToolState.data;
|
||||
|
||||
// Check if we already have toolData for this imageId and toolType
|
||||
if (toolData && toolData.length) {
|
||||
// If we have toolData, we should search it for any data related to the current Measurement
|
||||
const toolData = toolState[imageId][toolType].data;
|
||||
|
||||
// Create a flag so we know if we've successfully updated the Measurement in the toolData
|
||||
let alreadyExists = false;
|
||||
|
||||
// Loop through the toolData to search for this Measurement
|
||||
toolData.forEach(tool => {
|
||||
// Break the loop if this isn't the Measurement we are looking for
|
||||
if (tool._id !== measurement._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have found the Measurement, set the flag to True
|
||||
alreadyExists = true;
|
||||
|
||||
// Update the toolData from the Measurement data
|
||||
Object.assign(tool, measurement);
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we have found the Measurement we intended to update, we can stop this function here
|
||||
if (alreadyExists === true) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// If no toolData exists for this toolType, create an empty array to hold some
|
||||
toolState[imageId][toolType] = {
|
||||
data: []
|
||||
};
|
||||
}
|
||||
|
||||
// If we have reached this point, it means we haven't found the Measurement we are looking for
|
||||
// in the current toolData. This means we need to add it.
|
||||
|
||||
// Add the MeasurementData into the toolData for this imageId
|
||||
toolState[imageId][toolType].data.push(measurement);
|
||||
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager.restoreToolState(toolState);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import _ from 'underscore';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
OHIF.measurements.toggleLabelButton = options => {
|
||||
let { toolType } = options.measurement;
|
||||
const { tool } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
|
||||
if (!tool) return;
|
||||
|
||||
toolType = (tool && tool.parentTool) || toolType;
|
||||
|
||||
const measurementId = options.measurement._id;
|
||||
let buttonView = null;
|
||||
|
||||
const removeButtonView = () => {
|
||||
if (!buttonView) {
|
||||
return;
|
||||
}
|
||||
|
||||
Blaze.remove(buttonView);
|
||||
buttonView = null;
|
||||
};
|
||||
|
||||
if (buttonView) {
|
||||
removeButtonView();
|
||||
}
|
||||
|
||||
const measurementApi = options.measurementApi;
|
||||
const toolCollection = measurementApi.tools[toolType];
|
||||
const measurement = toolCollection.findOne(measurementId);
|
||||
|
||||
const data = {
|
||||
measurement,
|
||||
position: options.position,
|
||||
direction: options.direction,
|
||||
threeColumns: true,
|
||||
hideCommon: true,
|
||||
autoClick: options.autoClick,
|
||||
doneCallback: removeButtonView,
|
||||
updateCallback(location, description) {
|
||||
const groupId = measurementApi.toolsGroupsMap[toolType];
|
||||
const config = OHIF.measurements.MeasurementApi.getConfiguration();
|
||||
const group = _.findWhere(config.measurementTools, { id: groupId });
|
||||
group.childTools.forEach(tool => {
|
||||
measurementApi.tools[tool.id].update({
|
||||
measurementNumber: measurement.measurementNumber,
|
||||
patientId: measurement.patientId
|
||||
}, {
|
||||
$set: {
|
||||
location,
|
||||
description
|
||||
}
|
||||
}, {
|
||||
multi: true
|
||||
});
|
||||
});
|
||||
options.measurement.location = location;
|
||||
options.measurement.description = description;
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges('relabel');
|
||||
}
|
||||
};
|
||||
buttonView = Blaze.renderWithData(Template.measureFlow, data, document.body);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Extensible method to trigger unsaved changes on the active timepoint
|
||||
*
|
||||
* @param {String} subpath - The unsaved changes subpath that will come after the timepoint ID
|
||||
*/
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges = (subpath='changed') => {
|
||||
const basePath = 'viewer.studyViewer.measurements';
|
||||
const activeTimepoint = OHIF.measurements.getActiveTimepoint();
|
||||
if (!activeTimepoint) return;
|
||||
const { timepointId } = activeTimepoint;
|
||||
const timepointPath = timepointId ? `.${timepointId}` : '';
|
||||
OHIF.ui.unsavedChanges.set(`${basePath}${timepointPath}.${subpath}`);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
/**
|
||||
* Updates the measurements' description for a measurement number across all timepoints
|
||||
*
|
||||
* @param measurementData base measurement data that must contain toolType and measurementNumber
|
||||
* @param description measurement description that will be used
|
||||
*/
|
||||
OHIF.measurements.updateMeasurementsDescription = (measurementData, description) => {
|
||||
const { toolType, measurementNumber } = measurementData;
|
||||
measurementData.description = description;
|
||||
const filter = { measurementNumber };
|
||||
const operator = { $set: { description } };
|
||||
const options = { multi: true };
|
||||
const { toolGroup } = OHIF.measurements.getToolConfiguration(toolType);
|
||||
toolGroup.childTools.forEach(childTool => {
|
||||
const collection = OHIF.viewer.measurementApi.tools[childTool.id];
|
||||
collection.update(filter, operator, options);
|
||||
});
|
||||
|
||||
// Notify that viewer suffered changes
|
||||
OHIF.measurements.triggerTimepointUnsavedChanges('relabel');
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import _ from 'underscore';
|
||||
|
||||
export class BaseReport {
|
||||
constructor(options) {
|
||||
const defaultOptions = {
|
||||
width: 595.28,
|
||||
height: 841.89,
|
||||
marginTop: 30,
|
||||
marginLeft: 40,
|
||||
marginRight: 40,
|
||||
marginBottom: 30,
|
||||
showPageNumber: true
|
||||
};
|
||||
|
||||
this.options = _.extend(defaultOptions, options);
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.doc = new jsPDF('portrait', 'pt', [this.options.width, this.options.height]);
|
||||
this.options.width = Math.floor(this.options.width);
|
||||
this.options.height = Math.floor(this.options.height);
|
||||
this.currentPage = 1;
|
||||
this.printStatic();
|
||||
}
|
||||
|
||||
printStatic() {
|
||||
this.x = this.options.marginLeft;
|
||||
this.y = this.options.marginTop;
|
||||
this.printHeader();
|
||||
if (this.options.showPageNumber) {
|
||||
this.printPageNumber();
|
||||
}
|
||||
}
|
||||
|
||||
newPage() {
|
||||
this.doc.addPage();
|
||||
this.currentPage++;
|
||||
this.printStatic();
|
||||
}
|
||||
|
||||
printHeader() {
|
||||
const { marginLeft, marginRight, width } = this.options;
|
||||
const doc = this.doc;
|
||||
let y = this.y;
|
||||
|
||||
// Print the logo strokes
|
||||
doc.setDrawColor(0).setLineWidth(1);
|
||||
doc.roundedRect(marginLeft + 0.5, y + 0.5, 8, 8, 0.5, 0.5, 'D');
|
||||
doc.roundedRect(marginLeft + 11, y + 0.5, 8, 8, 0.5, 0.5, 'D');
|
||||
doc.roundedRect(marginLeft + 0.5, y + 11, 8, 8, 0.5, 0.5, 'D');
|
||||
doc.roundedRect(marginLeft + 11, y + 11, 8, 8, 0.5, 0.5, 'D');
|
||||
|
||||
// Print the logo text
|
||||
doc.setFont('Serif').setFontSize(16).setFontStyle('normal').setTextColor(0);
|
||||
doc.text('Open Health Imaging Foundation', 66, y + 14);
|
||||
y += 24;
|
||||
|
||||
// Print header horizontal line
|
||||
doc.setDrawColor(0).setLineWidth(0.5);
|
||||
doc.line(marginLeft, y, width - marginRight, y);
|
||||
y += 1;
|
||||
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
printPageNumber() {
|
||||
const doc = this.doc;
|
||||
const { marginBottom, marginRight, width, height } = this.options;
|
||||
doc.setFont('Verdana');
|
||||
doc.setFontSize(8);
|
||||
doc.setFontStyle('normal');
|
||||
doc.setTextColor(0);
|
||||
const text = `PAGE ${this.currentPage}`;
|
||||
const size = doc.getTextDimensions(text);
|
||||
doc.text(text, width - marginRight - size.w, height - marginBottom + (size.h / 2));
|
||||
}
|
||||
|
||||
save(fileName) {
|
||||
this.doc.save(fileName || 'report.pdf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { BaseReport } from './base';
|
||||
|
||||
export class MeasurementReport extends BaseReport {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
printHeader() {
|
||||
super.printHeader();
|
||||
|
||||
const { marginLeft, marginRight, width, header } = this.options;
|
||||
const doc = this.doc;
|
||||
let y = this.y;
|
||||
|
||||
// don't print the header if not given
|
||||
if (!header) return;
|
||||
|
||||
// Print trial label
|
||||
y += 10;
|
||||
doc.setFont('verdana');
|
||||
doc.setFontSize(8);
|
||||
doc.setFontStyle('bold');
|
||||
doc.setTextColor(255);
|
||||
doc.setFillColor(16);
|
||||
const trialLabel = header.trial;
|
||||
const trialLabelWidth = doc.getTextWidth(trialLabel) + 8;
|
||||
doc.roundedRect(marginLeft, y, trialLabelWidth, 15, 3, 3, 'F');
|
||||
doc.text(trialLabel, marginLeft + 4, y + 10.5);
|
||||
|
||||
// Print patient information
|
||||
doc.setFont('verdana');
|
||||
doc.setFontSize(10);
|
||||
doc.setFontStyle('normal');
|
||||
doc.setTextColor(0);
|
||||
doc.text(`${header.patientName}\t${header.mrn}`, marginLeft + trialLabelWidth + 10, y + 11);
|
||||
y += 25;
|
||||
|
||||
// Print timepoint header
|
||||
doc.setFillColor(229);
|
||||
doc.rect(marginLeft, y, width - marginLeft - marginRight, 18, 'F');
|
||||
doc.setFont('verdana');
|
||||
doc.setFontSize(9);
|
||||
doc.setFontStyle('normal');
|
||||
doc.setTextColor(0);
|
||||
doc.text(header.timepoint.toUpperCase(), marginLeft + 4, y + 12.5);
|
||||
y += 18;
|
||||
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
printMeasurement(measurementData) {
|
||||
const { marginLeft, marginRight, marginBottom, width, height } = this.options;
|
||||
const infoHeight = 28;
|
||||
const rectSize = Math.round((width - marginLeft - marginRight - 3) / 2);
|
||||
const doc = this.doc;
|
||||
let { x, y } = this;
|
||||
const { image, location, info } = measurementData;
|
||||
const type = measurementData.type.toUpperCase();
|
||||
const number = measurementData.number.toString();
|
||||
|
||||
if (y + rectSize + infoHeight > height - marginBottom) {
|
||||
this.newPage();
|
||||
x = this.x;
|
||||
y = this.y;
|
||||
}
|
||||
|
||||
// Print the image
|
||||
doc.setFillColor(0);
|
||||
doc.rect(x, y, rectSize, rectSize, 'F');
|
||||
doc.addImage(image, 'JPEG', x + 1, y + 1, rectSize - 2, rectSize - 2);
|
||||
y += rectSize;
|
||||
|
||||
// Print the measurement type
|
||||
doc.setFont('verdana').setFontSize(10).setFontStyle('bold').setTextColor(255);
|
||||
const typeWidth = Math.round(doc.getTextWidth(type));
|
||||
const typeX = x + rectSize - typeWidth;
|
||||
doc.setFillColor(64);
|
||||
doc.rect(typeX - 8, y - 16, typeWidth + 8, 16, 'F');
|
||||
doc.text(type, typeX - 4, y - 5);
|
||||
|
||||
// Print the measurement number
|
||||
doc.setFillColor(224);
|
||||
doc.circle(x + 9, y - 10, 7, 'F');
|
||||
doc.setFont('courier').setTextColor(0);
|
||||
const numberHalfWidth = doc.getTextWidth(number) / 2;
|
||||
doc.text(number, x + 9 - numberHalfWidth, y - 7);
|
||||
|
||||
// Print the measurement location and info
|
||||
doc.setFillColor(240);
|
||||
doc.rect(x, y, rectSize, infoHeight, 'F');
|
||||
doc.setFont('verdana').setFontSize(9);
|
||||
doc.text(location, x + 4, y + 11);
|
||||
doc.setFontStyle('normal');
|
||||
doc.text(info, x + 4, y + 24);
|
||||
y += infoHeight;
|
||||
|
||||
if (x === marginLeft) {
|
||||
this.x = width - marginRight - rectSize;
|
||||
} else {
|
||||
this.x = marginLeft;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import moment from 'moment';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
export const getExportMeasurementData = async (measurementApi, timepointApi) => {
|
||||
const currentTimepoint = timepointApi.current();
|
||||
const { timepointId } = currentTimepoint;
|
||||
const study = OHIF.viewer.Studies.findBy({
|
||||
studyInstanceUid: currentTimepoint.studyInstanceUids[0]
|
||||
});
|
||||
const { studyDescription, patientId, studyDate } = study;
|
||||
const patientName = OHIF.viewerbase.helpers.formatPN(study.patientName);
|
||||
|
||||
// All headers
|
||||
const measurementData = {
|
||||
patientName,
|
||||
mrn: patientId,
|
||||
studyDate: moment(studyDate).format('MMM DD YYYY'),
|
||||
studyDescription,
|
||||
data: []
|
||||
};
|
||||
|
||||
const addNewMeasurement = async (measurement) => {
|
||||
const imageId = OHIF.viewerbase.getImageIdForImagePath(measurement.imagePath);
|
||||
const { seriesDescription, seriesDate, modality, seriesInstanceUid } = cornerstone.metaData.get('series', imageId);
|
||||
const meanStdDev = measurement.meanStdDev || {};
|
||||
|
||||
measurementData.data.push({
|
||||
seriesModality: modality,
|
||||
seriesDate: moment(seriesDate).format('MMM DD YYYY'),
|
||||
seriesDescription,
|
||||
seriesInstanceUid,
|
||||
measurementTool: measurement.toolType,
|
||||
measurementDescription: OHIF.measurements.getLocationLabel(measurement.location) || 'No description',
|
||||
number: measurement.measurementNumber,
|
||||
length: measurement.length || '-',
|
||||
mean: meanStdDev.mean || '-',
|
||||
stdDev: meanStdDev.stdDev || '-',
|
||||
area: measurement.area || '-'
|
||||
});
|
||||
};
|
||||
|
||||
let allMeasurements = [];
|
||||
Object.keys(measurementApi.toolGroups).forEach( toolGroup => {
|
||||
let measurements = measurementApi.fetch(toolGroup, { timepointId });
|
||||
allMeasurements = allMeasurements.concat(measurements);
|
||||
});
|
||||
const iterator = allMeasurements[Symbol.iterator]();
|
||||
|
||||
let measurement;
|
||||
let current = iterator.next();
|
||||
while (!current.done) {
|
||||
measurement = current.value;
|
||||
await addNewMeasurement(measurement);
|
||||
current = iterator.next();
|
||||
}
|
||||
|
||||
return measurementData;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getExportMeasurementData } from "./reportMeasurementData";
|
||||
|
||||
const columnDelimiter = ',';
|
||||
const lineDelimiter = '\n';
|
||||
const headers = {
|
||||
patientName: 'Patient Name',
|
||||
mrn: 'MRN',
|
||||
studyDate: 'Study Date',
|
||||
seriesModality: 'Series Modality',
|
||||
seriesDate: 'Series Date',
|
||||
seriesDescription: 'Series Description',
|
||||
seriesInstanceUid: 'Series InstanceUid',
|
||||
measurementTool: 'Measurement Tool',
|
||||
measurementDescription: 'Measurement Description',
|
||||
length: 'Length',
|
||||
mean: 'Mean',
|
||||
stdDev: 'stdDev',
|
||||
area: 'area'
|
||||
};
|
||||
|
||||
export const getCSVMeasurementData = async (measurementApi, timepointApi) => {
|
||||
let lineData = [];
|
||||
let csvData = '';
|
||||
const dataObject = await getExportMeasurementData(measurementApi, timepointApi);
|
||||
|
||||
for(header in headers) {
|
||||
lineData.push(headers[header]);
|
||||
}
|
||||
csvData += lineData.join(columnDelimiter) + lineDelimiter;
|
||||
|
||||
for(measurementLine of dataObject.data) {
|
||||
lineData = [
|
||||
dataObject.patientName,
|
||||
dataObject.mrn,
|
||||
dataObject.studyDate,
|
||||
measurementLine.seriesModality,
|
||||
measurementLine.seriesDate,
|
||||
measurementLine.seriesDescription,
|
||||
measurementLine.seriesInstanceUid,
|
||||
measurementLine.measurementTool,
|
||||
measurementLine.measurementDescription,
|
||||
measurementLine.length,
|
||||
measurementLine.mean,
|
||||
measurementLine.stdDev,
|
||||
measurementLine.area
|
||||
];
|
||||
csvData += lineData.join(columnDelimiter) + lineDelimiter;
|
||||
}
|
||||
|
||||
return csvData;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import './measurements.js';
|
||||
import './timepoints.js';
|
||||
@@ -0,0 +1,318 @@
|
||||
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
|
||||
|
||||
const Measurement = new SimpleSchema({
|
||||
additionalData: {
|
||||
type: Object,
|
||||
label: 'Additional Data',
|
||||
defaultValue: {},
|
||||
optional: true,
|
||||
blackbox: true
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
label: 'User ID',
|
||||
optional: true
|
||||
},
|
||||
patientId: {
|
||||
type: String,
|
||||
label: 'Patient ID',
|
||||
optional: true
|
||||
},
|
||||
measurementNumber: {
|
||||
type: Number,
|
||||
label: 'Measurement Number',
|
||||
optional: true
|
||||
},
|
||||
timepointId: {
|
||||
type: String,
|
||||
label: 'Timepoint ID',
|
||||
optional: true
|
||||
},
|
||||
// Force value to be current date (on server) upon insert
|
||||
// and prevent updates thereafter.
|
||||
createdAt: {
|
||||
type: Date,
|
||||
autoValue: function() {
|
||||
if (this.isInsert) {
|
||||
return new Date();
|
||||
} else if (this.isUpsert) {
|
||||
return { $setOnInsert: new Date() };
|
||||
} else {
|
||||
// [PWV-184] Preventing unset due to child tools updating
|
||||
// this.unset(); // Prevent user from supplying their own value
|
||||
}
|
||||
}
|
||||
},
|
||||
// Force value to be current date (on server) upon update
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
autoValue: function() {
|
||||
if (this.isUpdate) {
|
||||
// return new Date();
|
||||
}
|
||||
},
|
||||
optional: true
|
||||
}
|
||||
});
|
||||
|
||||
const StudyLevelMeasurement = new SimpleSchema([
|
||||
Measurement,
|
||||
{
|
||||
studyInstanceUid: {
|
||||
type: String,
|
||||
label: 'Study Instance UID'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const SeriesLevelMeasurement = new SimpleSchema([
|
||||
StudyLevelMeasurement,
|
||||
{
|
||||
seriesInstanceUid: {
|
||||
type: String,
|
||||
label: 'Series Instance UID'
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const CornerstoneVOI = new SimpleSchema({
|
||||
windowWidth: {
|
||||
type: Number,
|
||||
label: 'Window Width',
|
||||
decimal: true,
|
||||
optional: true
|
||||
},
|
||||
windowCenter: {
|
||||
type: Number,
|
||||
label: 'Window Center',
|
||||
decimal: true,
|
||||
optional: true
|
||||
},
|
||||
});
|
||||
|
||||
const CornerstoneViewportTranslation = new SimpleSchema({
|
||||
x: {
|
||||
type: Number,
|
||||
label: 'X',
|
||||
decimal: true,
|
||||
optional: true
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
label: 'Y',
|
||||
decimal: true,
|
||||
optional: true
|
||||
},
|
||||
});
|
||||
|
||||
const CornerstoneViewport = new SimpleSchema({
|
||||
scale: {
|
||||
type: Number,
|
||||
label: 'Scale',
|
||||
decimal: true,
|
||||
optional: true
|
||||
},
|
||||
translation: {
|
||||
type: CornerstoneViewportTranslation,
|
||||
label: 'Translation',
|
||||
optional: true
|
||||
},
|
||||
voi: {
|
||||
type: CornerstoneVOI,
|
||||
label: 'VOI',
|
||||
optional: true
|
||||
},
|
||||
invert: {
|
||||
type: Boolean,
|
||||
label: 'Invert',
|
||||
optional: true
|
||||
},
|
||||
pixelReplication: {
|
||||
type: Boolean,
|
||||
label: 'Pixel Replication',
|
||||
optional: true
|
||||
},
|
||||
hFlip: {
|
||||
type: Boolean,
|
||||
label: 'Horizontal Flip',
|
||||
optional: true
|
||||
},
|
||||
vFlip: {
|
||||
type: Boolean,
|
||||
label: 'Vertical Flip',
|
||||
optional: true
|
||||
},
|
||||
rotation: {
|
||||
type: Number,
|
||||
label: 'Rotation (degrees)',
|
||||
decimal: true,
|
||||
optional: true
|
||||
}
|
||||
});
|
||||
|
||||
const InstanceLevelMeasurement = new SimpleSchema([
|
||||
StudyLevelMeasurement,
|
||||
SeriesLevelMeasurement,
|
||||
{
|
||||
sopInstanceUid: {
|
||||
type: String,
|
||||
label: 'SOP Instance UID'
|
||||
},
|
||||
viewport: {
|
||||
type: CornerstoneViewport,
|
||||
label: 'Viewport Parameters',
|
||||
optional: true
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const FrameLevelMeasurement = new SimpleSchema([
|
||||
StudyLevelMeasurement,
|
||||
SeriesLevelMeasurement,
|
||||
InstanceLevelMeasurement,
|
||||
{
|
||||
frameIndex: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
label: 'Frame index in Instance'
|
||||
},
|
||||
imagePath: {
|
||||
type: String,
|
||||
label: 'Identifier for the measurement\'s image' // studyInstanceUid_seriesInstanceUid_sopInstanceUid_frameIndex
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const CornerstoneToolMeasurement = new SimpleSchema([
|
||||
StudyLevelMeasurement,
|
||||
SeriesLevelMeasurement,
|
||||
InstanceLevelMeasurement,
|
||||
FrameLevelMeasurement,
|
||||
{
|
||||
toolType: {
|
||||
type: String,
|
||||
label: 'Cornerstone Tool Type',
|
||||
optional: true
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
label: 'Visible',
|
||||
defaultValue: true
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
label: 'Active',
|
||||
defaultValue: false
|
||||
},
|
||||
invalidated: {
|
||||
type: Boolean,
|
||||
label: 'Invalidated',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const CornerstoneHandleBoundingBoxSchema = new SimpleSchema({
|
||||
width: {
|
||||
type: Number,
|
||||
label: 'Width',
|
||||
decimal: true
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
label: 'Height',
|
||||
decimal: true
|
||||
},
|
||||
left: {
|
||||
type: Number,
|
||||
label: 'Left',
|
||||
decimal: true
|
||||
},
|
||||
top: {
|
||||
type: Number,
|
||||
label: 'Top',
|
||||
decimal: true
|
||||
}
|
||||
});
|
||||
|
||||
const CornerstoneHandleSchema = new SimpleSchema({
|
||||
x: {
|
||||
type: Number,
|
||||
label: 'X',
|
||||
decimal: true,
|
||||
optional: true // Not actually optional, but sometimes values like x/y position are missing
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
label: 'Y',
|
||||
decimal: true,
|
||||
optional: true // Not actually optional, but sometimes values like x/y position are missing
|
||||
},
|
||||
highlight: {
|
||||
type: Boolean,
|
||||
label: 'Highlight',
|
||||
defaultValue: false
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
label: 'Active',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
drawnIndependently: {
|
||||
type: Boolean,
|
||||
label: 'Drawn Independently',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
movesIndependently: {
|
||||
type: Boolean,
|
||||
label: 'Moves Independently',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
allowedOutsideImage: {
|
||||
type: Boolean,
|
||||
label: 'Allowed Outside Image',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
hasMoved: {
|
||||
type: Boolean,
|
||||
label: 'Has Already Moved',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
hasBoundingBox: {
|
||||
type: Boolean,
|
||||
label: 'Has Bounding Box',
|
||||
defaultValue: false,
|
||||
optional: true
|
||||
},
|
||||
boundingBox: {
|
||||
type: CornerstoneHandleBoundingBoxSchema,
|
||||
label: 'Bounding Box',
|
||||
optional: true
|
||||
},
|
||||
index: { // TODO: Remove 'index' from bidirectionalTool since it's useless
|
||||
type: Number,
|
||||
optional: true
|
||||
},
|
||||
locked: {
|
||||
type: Boolean,
|
||||
label: 'Locked',
|
||||
optional: true,
|
||||
defaultValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export const MeasurementSchemaTypes = {
|
||||
Measurement: Measurement,
|
||||
StudyLevelMeasurement: StudyLevelMeasurement,
|
||||
SeriesLevelMeasurement: SeriesLevelMeasurement,
|
||||
InstanceLevelMeasurement: InstanceLevelMeasurement,
|
||||
FrameLevelMeasurement: FrameLevelMeasurement,
|
||||
CornerstoneToolMeasurement: CornerstoneToolMeasurement,
|
||||
CornerstoneHandleSchema: CornerstoneHandleSchema
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
|
||||
|
||||
export const schema = new SimpleSchema({
|
||||
patientId: {
|
||||
type: String,
|
||||
label: 'Patient ID',
|
||||
optional: true
|
||||
},
|
||||
timepointId: {
|
||||
type: String,
|
||||
label: 'Timepoint ID'
|
||||
},
|
||||
timepointType: {
|
||||
type: String,
|
||||
label: 'Timepoint Type',
|
||||
allowedValues: ['baseline', 'followup'],
|
||||
defaultValue: 'baseline',
|
||||
},
|
||||
isLocked: {
|
||||
type: Boolean,
|
||||
label: 'Timepoint Locked'
|
||||
},
|
||||
studyInstanceUids: {
|
||||
type: [String],
|
||||
label: 'Study Instance Uids',
|
||||
defaultValue: []
|
||||
},
|
||||
earliestDate: {
|
||||
type: Date,
|
||||
label: 'Earliest Study Date from associated studies',
|
||||
},
|
||||
latestDate: {
|
||||
type: Date,
|
||||
label: 'Most recent Study Date from associated studies',
|
||||
},
|
||||
visitNumber: {
|
||||
type: Number,
|
||||
label: 'Number of patient\'s visit',
|
||||
optional: true
|
||||
},
|
||||
studiesData: {
|
||||
type: [Object],
|
||||
label: 'Studies data to allow lazy loading',
|
||||
optional: true,
|
||||
blackbox: true
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user