reorganize packages, start converting overall app to react (very broken at the moment)

This commit is contained in:
Erik Ziegler committed 2018-12-12 18:06:25 +01:00
1 parent 024fe1a448
commit 5f30f7bd35
479 files changed
+5364 -11611

No files matched your search

+3
View File
@@ -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);
});
+6
View File
@@ -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
};
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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
}
});