Initial work on Nucleus parity merge
This commit is contained in:
1 parent
aa88fe181a
commit
680beb163f
38 files changed
+4353
-1
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { Random } from 'meteor/random';
|
||||
import { Metadata } from './metadata/Metadata';
|
||||
import { InstanceMetadata } from './metadata/InstanceMetadata';
|
||||
|
||||
export class DisplaySet {
|
||||
|
||||
constructor(instances) {
|
||||
this._uid = Random.id();
|
||||
this._attributes = {};
|
||||
this._instances = [];
|
||||
|
||||
if (instances instanceof Array) {
|
||||
instances.forEach( instance => this.addInstance(instance));
|
||||
}
|
||||
}
|
||||
|
||||
getUID() {
|
||||
return this._uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the number of instances within the current display set.
|
||||
* @returns {number} The number of instances in the current display set.
|
||||
*/
|
||||
getInstanceCount() {
|
||||
return this._instances.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an instance by index.
|
||||
* @param {number} index An integer representing a list index.
|
||||
* @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise.
|
||||
*/
|
||||
getInstanceByIndex(index) {
|
||||
let found; // undefined by default...
|
||||
if (Metadata.isValidIndex(index)) {
|
||||
found = this._instances[index];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the supplied callback for each instance in the current display set passing
|
||||
* two arguments: instance (InstanceMetadata) and index (the integer index of the instance within the current display set)
|
||||
* @param {function} callback The callback function which will be invoked for each instance in the display set.
|
||||
* @returns {undefined} Nothing is returned.
|
||||
*/
|
||||
forEachInstance(callback) {
|
||||
if (Metadata.isValidCallback(callback)) {
|
||||
this._instances.forEach((instance, index) => {
|
||||
callback.call(null, instance, index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an instance to the current series.
|
||||
* @param {InstanceMetadata} instance The instance to be added to the current series.
|
||||
* @returns {boolean} Returns true on success, false otherwise.
|
||||
*/
|
||||
addInstance(instance) {
|
||||
let result = false;
|
||||
if (instance instanceof InstanceMetadata) {
|
||||
this._instances.push(instance);
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setAttribute(attribute, value) {
|
||||
this._attributes[attribute] = value;
|
||||
}
|
||||
|
||||
getAttribute(attribute) {
|
||||
return this._attributes[attribute];
|
||||
}
|
||||
|
||||
setAttributes(attributes) {
|
||||
if (typeof attributes === 'object' && attributes !== null) {
|
||||
const _attributes = this._attributes;
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
for (let attribute in attributes) {
|
||||
if (hasOwn.call(attributes, attribute)) {
|
||||
_attributes[attribute] = attributes[attribute];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Random } from 'meteor/random';
|
||||
|
||||
/**
|
||||
* This class defines an ImageSet object which will be used across the viewer. This object represents
|
||||
* a list of images that are associated by any arbitrary criteria being thus content agnostic. Besides the
|
||||
* main attributes (images and uid) it allows additional attributes to be appended to it (currently
|
||||
* indiscriminately, but this should be changed).
|
||||
*/
|
||||
export class ImageSet {
|
||||
|
||||
constructor(images) {
|
||||
|
||||
if (Array.isArray(images) !== true) {
|
||||
throw new TypeError('ImageSet expects an array of images...');
|
||||
}
|
||||
|
||||
// Main ImageSet attributes
|
||||
this.images = images; // Array of images
|
||||
this.uid = Random.id(); // Unique ID of the instance
|
||||
|
||||
}
|
||||
|
||||
setAttribute(attribute, value) {
|
||||
this[attribute] = value;
|
||||
}
|
||||
|
||||
getAttribute(attribute) {
|
||||
return this[attribute];
|
||||
}
|
||||
|
||||
setAttributes(attributes) {
|
||||
if (typeof attributes === 'object' && attributes !== null) {
|
||||
const imageSet = this, hasOwn = Object.prototype.hasOwnProperty;
|
||||
for (let attribute in attributes) {
|
||||
if (hasOwn.call(attributes, attribute)) {
|
||||
imageSet[attribute] = attributes[attribute];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getImage(index) {
|
||||
return this.images[index];
|
||||
}
|
||||
|
||||
sortBy(sortingCallback) {
|
||||
return this.images.sort(sortingCallback);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { Session } from 'meteor/session';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Random } from 'meteor/random';
|
||||
import { _ } from 'meteor/underscore';
|
||||
import { $ } from 'meteor/jquery';
|
||||
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
|
||||
// Displays Series in Viewports given a Protocol and list of Studies
|
||||
export class LayoutManager {
|
||||
constructor(parentNode, studies) {
|
||||
OHIF.log.info('LayoutManager constructor');
|
||||
|
||||
this.parentNode = parentNode;
|
||||
this.studies = studies;
|
||||
this.viewportData = [];
|
||||
this.layoutTemplateName = 'gridLayout';
|
||||
this.layoutProps = {
|
||||
rows: 1,
|
||||
columns: 1
|
||||
};
|
||||
|
||||
this.isZoomed = false;
|
||||
|
||||
const updateSessionFn = () => Tracker.afterFlush(() => Session.set('LayoutManagerUpdated', Random.id()));
|
||||
this.updateSession = _.throttle(updateSessionFn, 300);
|
||||
}
|
||||
|
||||
getNumberOfViewports() {
|
||||
return this.layoutProps.rows * this.layoutProps.columns;
|
||||
}
|
||||
|
||||
setDefaultViewportData() {
|
||||
OHIF.log.info('LayoutManager setDefaultViewportData');
|
||||
|
||||
const self = this;
|
||||
|
||||
// Get the number of vieports to be rendered
|
||||
const viewportsAmount = this.getNumberOfViewports();
|
||||
|
||||
// Store the old viewport data and reset the current
|
||||
const oldViewportData = self.viewportData;
|
||||
|
||||
// Get the studies and display sets sequence map
|
||||
const sequenceMap = this.getDisplaySetSequenceMap();
|
||||
|
||||
// Check if the display sets are sequenced
|
||||
const isSequenced = this.isDisplaySetsSequenced(sequenceMap);
|
||||
|
||||
// Define the current viewport index and the viewport data array
|
||||
let currentViewportIndex = 0;
|
||||
if (viewportsAmount > oldViewportData.length && oldViewportData.length && isSequenced) {
|
||||
// Keep the displayed display sets
|
||||
self.viewportData = oldViewportData;
|
||||
currentViewportIndex = oldViewportData.length;
|
||||
}
|
||||
else if (viewportsAmount <= oldViewportData.length) {
|
||||
// Reduce the original displayed display sets
|
||||
self.viewportData = oldViewportData.slice(0, viewportsAmount);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
// Reset all display sets
|
||||
self.viewportData = [];
|
||||
}
|
||||
|
||||
// Get all the display sets for the viewer studies
|
||||
let displaySets = [];
|
||||
this.studies.forEach(study => {
|
||||
study.series.forEach(displaySet => {
|
||||
displaySet.images.length && displaySets.push(displaySet);
|
||||
});
|
||||
});
|
||||
|
||||
// Get the display sets that will be appended to the current ones
|
||||
let appendix;
|
||||
const currentLength = self.viewportData.length;
|
||||
if (currentLength) {
|
||||
// TODO: isolate displaySets array by study (maybe a map?)
|
||||
const beginIndex = sequenceMap.values().next().value[0].displaySetIndex + currentLength;
|
||||
const endIndex = beginIndex + (viewportsAmount - currentLength);
|
||||
appendix = displaySets.slice(beginIndex, endIndex);
|
||||
}
|
||||
else {
|
||||
// Get available display sets from the first to the grid size
|
||||
appendix = displaySets.slice(0, viewportsAmount);
|
||||
}
|
||||
|
||||
// Generate the additional data based on the appendix
|
||||
const additionalData = [];
|
||||
appendix.forEach((displaySet, index) => {
|
||||
additionalData.push({
|
||||
viewportIndex: currentViewportIndex + index,
|
||||
studyInstanceUid: displaySet.studyInstanceUid,
|
||||
seriesInstanceUid: displaySet.seriesInstanceUid,
|
||||
displaySetInstanceUid: displaySet.displaySetInstanceUid,
|
||||
sopInstanceUid: displaySet.images[0].sopInstanceUid
|
||||
});
|
||||
});
|
||||
|
||||
// Append the additional data with the viewport data
|
||||
self.viewportData = self.viewportData.concat(additionalData);
|
||||
|
||||
// Push empty objects if the amount is lesser than the grid size
|
||||
while (self.viewportData.length < viewportsAmount) {
|
||||
self.viewportData.push({});
|
||||
}
|
||||
}
|
||||
|
||||
updateViewports() {
|
||||
OHIF.log.info('LayoutManager updateViewports');
|
||||
|
||||
if (!this.viewportData ||
|
||||
!this.viewportData.length ||
|
||||
this.viewportData.length !== this.getNumberOfViewports()) {
|
||||
this.setDefaultViewportData();
|
||||
}
|
||||
|
||||
// imageViewerViewports occasionally needs relevant layout data in order to set
|
||||
// the element style of the viewport in question
|
||||
const layoutProps = this.layoutProps;
|
||||
const data = $.extend({
|
||||
viewportData: []
|
||||
}, layoutProps);
|
||||
|
||||
this.viewportData.forEach( viewportData => {
|
||||
const viewportDataAndLayoutProps = $.extend(viewportData, layoutProps);
|
||||
data.viewportData.push(viewportDataAndLayoutProps);
|
||||
});
|
||||
|
||||
const layoutTemplate = Template[this.layoutTemplateName];
|
||||
|
||||
$(this.parentNode).html('');
|
||||
Blaze.renderWithData(layoutTemplate, data, this.parentNode);
|
||||
|
||||
this.updateSession();
|
||||
|
||||
this.isZoomed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function destroys and re-renders the imageViewerViewport template.
|
||||
* It uses the data provided to load a new display set into the produced viewport.
|
||||
*
|
||||
* @param viewportIndex
|
||||
* @param data
|
||||
*/
|
||||
rerenderViewportWithNewDisplaySet(viewportIndex, data) {
|
||||
OHIF.log.info(`LayoutManager rerenderViewportWithNewDisplaySet: ${viewportIndex}`);
|
||||
|
||||
// The parent container is identified because it is later removed from the DOM
|
||||
const element = $('.imageViewerViewport').eq(viewportIndex);
|
||||
const container = element.parents('.viewportContainer').get(0);
|
||||
|
||||
// Record the current viewportIndex so this can be passed into the re-rendering call
|
||||
data.viewportIndex = viewportIndex;
|
||||
|
||||
// Update the dictionary of loaded displaySet for the specified viewport
|
||||
this.viewportData[viewportIndex] = {
|
||||
viewportIndex: viewportIndex,
|
||||
displaySetInstanceUid: data.displaySetInstanceUid,
|
||||
seriesInstanceUid: data.seriesInstanceUid,
|
||||
studyInstanceUid: data.studyInstanceUid,
|
||||
renderedCallback: data.renderedCallback,
|
||||
currentImageIdIndex: data.currentImageIdIndex || 0
|
||||
};
|
||||
|
||||
// Remove the hover styling
|
||||
element.find('canvas').not('.magnifyTool').removeClass('faded');
|
||||
|
||||
// Remove the whole template, add in the new one
|
||||
const viewportContainer = element.parents('.removable');
|
||||
|
||||
const newViewportContainer = document.createElement('div');
|
||||
newViewportContainer.className = 'removable';
|
||||
|
||||
// Remove the parent element of the template
|
||||
// This is a workaround since otherwise Blaze UI onDestroyed doesn't fire
|
||||
viewportContainer.remove();
|
||||
|
||||
container.appendChild(newViewportContainer);
|
||||
|
||||
// Render and insert the template
|
||||
Blaze.renderWithData(Template.imageViewerViewport, data, newViewportContainer);
|
||||
|
||||
this.updateSession();
|
||||
}
|
||||
|
||||
enlargeViewport(viewportIndex) {
|
||||
OHIF.log.info(`LayoutManager enlargeViewport: ${viewportIndex}`);
|
||||
|
||||
if (!this.viewportData ||
|
||||
!this.viewportData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone the array for later
|
||||
this.previousViewportData = this.viewportData.slice(0);
|
||||
|
||||
const singleViewportData = $.extend({}, this.viewportData[viewportIndex]);
|
||||
singleViewportData.rows = 1;
|
||||
singleViewportData.columns = 1;
|
||||
singleViewportData.viewportIndex = 0;
|
||||
|
||||
const data = {
|
||||
viewportData: [singleViewportData],
|
||||
rows: 1,
|
||||
columns: 1
|
||||
};
|
||||
|
||||
const layoutTemplate = Template['gridLayout'];
|
||||
|
||||
$(this.parentNode).html('');
|
||||
Blaze.renderWithData(layoutTemplate, data, this.parentNode);
|
||||
|
||||
this.isZoomed = true;
|
||||
this.zoomedViewportIndex = viewportIndex;
|
||||
this.viewportData = data.viewportData;
|
||||
|
||||
this.updateSession();
|
||||
}
|
||||
|
||||
resetPreviousLayout() {
|
||||
OHIF.log.info('LayoutManager resetPreviousLayout');
|
||||
|
||||
if (!this.isZoomed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousViewportData[this.zoomedViewportIndex] = $.extend({}, this.viewportData[0]);
|
||||
this.previousViewportData[this.zoomedViewportIndex].viewportIndex = this.zoomedViewportIndex;
|
||||
this.viewportData = this.previousViewportData;
|
||||
this.updateViewports();
|
||||
}
|
||||
|
||||
toggleEnlargement(viewportIndex) {
|
||||
OHIF.log.info(`LayoutManager toggleEnlargement: ${viewportIndex}`);
|
||||
|
||||
if (this.isZoomed) {
|
||||
this.resetPreviousLayout();
|
||||
}
|
||||
else {
|
||||
// Don't enlarge the viewport if we only have one Viewport
|
||||
// to begin with
|
||||
if (this.getNumberOfViewports() > 1) {
|
||||
this.enlargeViewport(viewportIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the display sets map sequence of display sets and viewports
|
||||
getDisplaySetSequenceMap() {
|
||||
OHIF.log.info('LayoutManager getDisplaySetSequenceMap');
|
||||
|
||||
// Get the viewport data list
|
||||
const viewportDataList = this.viewportData;
|
||||
|
||||
// Create a map to control the display set sequence
|
||||
const sequenceMap = new Map();
|
||||
|
||||
// Iterate over each viewport and register its details on the sequence map
|
||||
viewportDataList.forEach((viewportData, viewportIndex) => {
|
||||
// Get the current study
|
||||
const currentStudy = _.findWhere(this.studies, {
|
||||
studyInstanceUid: viewportData.studyInstanceUid
|
||||
}) || this.studies[0];
|
||||
|
||||
// Get the display sets
|
||||
const displaySets = currentStudy.series;
|
||||
|
||||
// Get the current display set
|
||||
const displaySet = _.findWhere(displaySets, {
|
||||
displaySetInstanceUid: viewportData.displaySetInstanceUid
|
||||
});
|
||||
|
||||
// Get the current instance index (using 9999 to sort greater than -1)
|
||||
let displaySetIndex = _.indexOf(displaySets, displaySet);
|
||||
displaySetIndex = displaySetIndex < 0 ? 9999 : displaySetIndex;
|
||||
|
||||
// Try to get a map entry for current study or create it if not present
|
||||
let studyViewports = sequenceMap.get(currentStudy);
|
||||
if (!studyViewports) {
|
||||
studyViewports = [];
|
||||
sequenceMap.set(currentStudy, studyViewports);
|
||||
}
|
||||
|
||||
// Register the viewport index and the display set index on the map
|
||||
studyViewports.push({
|
||||
viewportIndex,
|
||||
displaySetIndex
|
||||
});
|
||||
});
|
||||
|
||||
// Return the generated sequence map
|
||||
return sequenceMap;
|
||||
}
|
||||
|
||||
// Check if all the display sets and viewports are sequenced
|
||||
isDisplaySetsSequenced(definedSequenceMap) {
|
||||
OHIF.log.info('LayoutManager isDisplaySetsSequenced');
|
||||
|
||||
let isSequenced = true;
|
||||
|
||||
// Get the studies and display sets sequence map
|
||||
const sequenceMap = definedSequenceMap || this.getDisplaySetSequenceMap();
|
||||
|
||||
sequenceMap.forEach((studyViewports, study) => {
|
||||
let lastDisplaySetIndex = null;
|
||||
let lastViewportIndex = null;
|
||||
studyViewports.forEach(({ viewportIndex, displaySetIndex }, index) => {
|
||||
// Check if the sequence is wrong
|
||||
if (
|
||||
displaySetIndex !== 9999 &&
|
||||
lastViewportIndex !== null &&
|
||||
lastDisplaySetIndex !== null &&
|
||||
displaySetIndex !== null &&
|
||||
(viewportIndex - 1 !== lastViewportIndex ||
|
||||
displaySetIndex - 1 !== lastDisplaySetIndex)
|
||||
) {
|
||||
// Set the sequenced flag as false;
|
||||
isSequenced = false;
|
||||
}
|
||||
|
||||
// Update the last viewport index
|
||||
lastViewportIndex = viewportIndex;
|
||||
|
||||
// Update the last display set index
|
||||
lastDisplaySetIndex = displaySetIndex;
|
||||
});
|
||||
});
|
||||
|
||||
return isSequenced;
|
||||
}
|
||||
|
||||
// Check if is possible to move display sets on a specific direction
|
||||
canMoveDisplaySets(isNext) {
|
||||
OHIF.log.info('LayoutManager canMoveDisplaySets');
|
||||
|
||||
// Get the setting that defines if the display set navigation is multiple
|
||||
const isMultiple = OHIF.uiSettings.displaySetNavigationMultipleViewports;
|
||||
|
||||
// Get the setting that allow display set navigation looping over series
|
||||
const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries;
|
||||
|
||||
// Get the studies and display sets sequence map
|
||||
const sequenceMap = this.getDisplaySetSequenceMap();
|
||||
|
||||
// Check if the display sets are sequenced
|
||||
const isSequenced = this.isDisplaySetsSequenced(sequenceMap);
|
||||
|
||||
// Get Active Viewport Index if isMultiple is false
|
||||
const activeViewportIndex = !isMultiple ? Session.get('activeViewport') : null;
|
||||
|
||||
// Check if is next and looping is blocked
|
||||
if (isNext && !allowLooping) {
|
||||
// Check if the end was reached
|
||||
let endReached = true;
|
||||
|
||||
sequenceMap.forEach((studyViewports, study) => {
|
||||
// Get active viewport index if isMultiple is false ortherwise get last
|
||||
const studyViewport = studyViewports[activeViewportIndex !== null ? activeViewportIndex : studyViewports.length - 1];
|
||||
if (!studyViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportIndex = studyViewport.displaySetIndex;
|
||||
const layoutViewports = studyViewports.length;
|
||||
const amount = study.displaySets.length;
|
||||
const move = !isMultiple ? 1 : ((amount % layoutViewports) || layoutViewports);
|
||||
const lastStepIndex = amount - move;
|
||||
|
||||
// 9999 for index means empty viewport, see getDisplaySetSequenceMap function
|
||||
if (viewportIndex !== 9999 && viewportIndex !== lastStepIndex) {
|
||||
endReached = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Return false if end is not reached yet
|
||||
if ((!isMultiple || isSequenced) && endReached) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if is previous and looping is blocked
|
||||
if (!isNext && !allowLooping) {
|
||||
// Check if the begin was reached
|
||||
let beginReached = true;
|
||||
|
||||
if(activeViewportIndex >= 0) {
|
||||
sequenceMap.forEach((studyViewports, study) => {
|
||||
// Get active viewport index if isMultiple is false ortherwise get first
|
||||
const studyViewport = studyViewports[activeViewportIndex !== null ? activeViewportIndex : 0];
|
||||
if (!studyViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportIndex = studyViewport.displaySetIndex;
|
||||
const layoutViewports = studyViewports.length;
|
||||
|
||||
// 9999 for index means empty viewport, see getDisplaySetSequenceMap function
|
||||
if (viewportIndex !== 9999 && viewportIndex - layoutViewports !== -layoutViewports) {
|
||||
beginReached = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return false if begin is not reached yet
|
||||
if ((!isMultiple || isSequenced) && beginReached) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move display sets forward or backward in the given viewport index
|
||||
moveSingleViewportDisplaySets(viewportIndex, isNext) {
|
||||
OHIF.log.info(`LayoutManager moveSingleViewportDisplaySets: ${viewportIndex}`);
|
||||
|
||||
// Get the setting that allow display set navigation looping over series
|
||||
const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries;
|
||||
|
||||
// Get the selected viewport data
|
||||
const viewportData = this.viewportData[viewportIndex];
|
||||
|
||||
// Get the current study
|
||||
const currentStudy = _.findWhere(this.studies, {
|
||||
studyInstanceUid: viewportData.studyInstanceUid
|
||||
}) || this.studies[0];
|
||||
|
||||
// Get the display sets
|
||||
const displaySets = currentStudy.displaySets;
|
||||
|
||||
// Get the current display set
|
||||
const currentDisplaySet = _.findWhere(displaySets, {
|
||||
displaySetInstanceUid: viewportData.displaySetInstanceUid
|
||||
});
|
||||
|
||||
// Get the new index and ensure that it will exists in display sets
|
||||
let newIndex = _.indexOf(displaySets, currentDisplaySet);
|
||||
if (isNext) {
|
||||
newIndex++;
|
||||
if (newIndex >= displaySets.length) {
|
||||
// Stop here if looping is not allowed
|
||||
if (!allowLooping) {
|
||||
return;
|
||||
}
|
||||
|
||||
newIndex = 0;
|
||||
}
|
||||
} else {
|
||||
newIndex--;
|
||||
if (newIndex < 0) {
|
||||
// Stop here if looping is not allowed
|
||||
if (!allowLooping) {
|
||||
return;
|
||||
}
|
||||
|
||||
newIndex = displaySets.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the display set data for the new index
|
||||
const newDisplaySetData = displaySets[newIndex];
|
||||
|
||||
// Rerender the viewport using the new display set data
|
||||
this.rerenderViewportWithNewDisplaySet(viewportIndex, newDisplaySetData);
|
||||
}
|
||||
|
||||
// Move multiple display sets forward or backward in all viewports
|
||||
moveMultipleViewportDisplaySets(isNext) {
|
||||
OHIF.log.info('LayoutManager moveMultipleViewportDisplaySets');
|
||||
|
||||
// Get the setting that allow display set navigation looping over series
|
||||
const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries;
|
||||
|
||||
// Create a map to control the display set sequence
|
||||
const sequenceMap = new this.getDisplaySetSequenceMap();
|
||||
|
||||
// Check if the display sets are sequenced
|
||||
const isSequenced = this.isDisplaySetsSequenced(sequenceMap);
|
||||
|
||||
const displaySetsToRender = [];
|
||||
|
||||
// Iterate over the studies map and move its display sets
|
||||
sequenceMap.forEach((studyViewports, study) => {
|
||||
// Sort the viewports on the study by the display set index
|
||||
studyViewports.sort((a, b) => a.displaySetIndex > b.displaySetIndex);
|
||||
|
||||
// Get the study display sets
|
||||
const displaySets = study.displaySets;
|
||||
|
||||
// Calculate the base index
|
||||
const firstIndex = studyViewports[0].displaySetIndex;
|
||||
const steps = studyViewports.length;
|
||||
const rest = firstIndex % steps;
|
||||
let baseIndex = rest ? firstIndex - rest : firstIndex;
|
||||
const direction = isNext ? 1 : -1;
|
||||
baseIndex += steps * direction;
|
||||
|
||||
const amount = displaySets.length;
|
||||
|
||||
// Check if the indexes are sequenced or will overflow the array bounds
|
||||
if (baseIndex >= amount) {
|
||||
const move = (amount % steps) || steps;
|
||||
const lastStepIndex = amount - move;
|
||||
if (firstIndex + steps !== lastStepIndex + steps) {
|
||||
// Reset the index if the display sets are sequenced but shifted
|
||||
baseIndex = lastStepIndex;
|
||||
} else if (!allowLooping) {
|
||||
// Stop here if looping is not allowed
|
||||
return;
|
||||
} else {
|
||||
// Start over the series if looping is allowed
|
||||
baseIndex = 0;
|
||||
}
|
||||
} else if (baseIndex < 0) {
|
||||
if (firstIndex > 0) {
|
||||
// Reset the index if the display sets are sequenced but shifted
|
||||
baseIndex = 0;
|
||||
} else if (!allowLooping) {
|
||||
// Stop here if looping is not allowed
|
||||
return;
|
||||
} else {
|
||||
// Go to the series' end if looping is allowed
|
||||
baseIndex = (amount - 1) - ((amount - 1) % steps);
|
||||
}
|
||||
} else if (!isSequenced) {
|
||||
// Reset the sequence if indexes are not sequenced
|
||||
baseIndex = 0;
|
||||
}
|
||||
|
||||
// Iterate over the current study viewports
|
||||
studyViewports.forEach(({ viewportIndex }, index) => {
|
||||
// Get the new displaySet index to be rendered in viewport
|
||||
const newIndex = baseIndex + index;
|
||||
|
||||
// Get the display set data for the new index
|
||||
const displaySetData = displaySets[newIndex] || {};
|
||||
|
||||
// Add the current display set that on the render list
|
||||
displaySetsToRender.push(displaySetData);
|
||||
});
|
||||
});
|
||||
|
||||
// Sort the display sets
|
||||
const sortingFunction = OHIF.utils.sortBy({
|
||||
name: 'studyInstanceUid'
|
||||
}, {
|
||||
name: 'instanceNumber'
|
||||
}, {
|
||||
name: 'seriesNumber'
|
||||
});
|
||||
displaySetsToRender.sort((a, b) => sortingFunction(a, b));
|
||||
|
||||
// Iterate over each display set data and render on its respective viewport
|
||||
displaySetsToRender.forEach((data, index) => {
|
||||
this.rerenderViewportWithNewDisplaySet(index, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Move display sets forward or backward
|
||||
moveDisplaySets(isNext) {
|
||||
OHIF.log.info('LayoutManager moveDisplaySets');
|
||||
|
||||
//Check if navigation is on a single or multiple viewports
|
||||
if (OHIF.uiSettings.displaySetNavigationMultipleViewports) {
|
||||
// Move display sets on multiple viewports
|
||||
this.moveMultipleViewportDisplaySets(isNext);
|
||||
} else {
|
||||
// Get the selected viewport index
|
||||
const viewportIndex = Session.get('activeViewport');
|
||||
|
||||
// Move display sets on a single viewport
|
||||
this.moveSingleViewportDisplaySets(viewportIndex, isNext);
|
||||
}
|
||||
}
|
||||
|
||||
isStudyLoadedIntoViewport(studyInstanceUid, viewportIndex) {
|
||||
return (this.viewportData.find(item => item.studyInstanceUid === studyInstanceUid && item.viewportIndex === viewportIndex) !== void 0);
|
||||
}
|
||||
|
||||
isMultipleLayout() {
|
||||
return this.layoutProps.row !== 1 && this.layoutProps.columns !== 1;
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
// @TODO: improve this object
|
||||
/**
|
||||
* Objects to be used to throw errors, specially
|
||||
* in Trackers functions (afterFlush, Flush).
|
||||
*/
|
||||
export class OHIFError extends Error {
|
||||
|
||||
constructor(message) {
|
||||
super();
|
||||
this.message = message;
|
||||
this.stack = (new Error()).stack;
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Session } from 'meteor/session';
|
||||
import { $ } from 'meteor/jquery';
|
||||
import { OHIF } from 'meteor/ohif:core';
|
||||
import { getInstanceClassDefaultViewport } from '../instanceClassSpecificViewport';
|
||||
|
||||
// Manage resizing viewports triggered by window resize
|
||||
export class ResizeViewportManager {
|
||||
constructor() {
|
||||
OHIF.log.info('ResizeViewportManager');
|
||||
}
|
||||
|
||||
// Reposition Study Series Quick Switch based whether side bars are opened or not
|
||||
repositionStudySeriesQuickSwitch() {
|
||||
OHIF.log.info('ResizeViewportManager repositionStudySeriesQuickSwitch');
|
||||
|
||||
const activeTab = Session.get('activeContentId');
|
||||
if(activeTab === 'viewerTab') {
|
||||
const nViewports = OHIF.viewerbase.layoutManager.viewportData.length;
|
||||
|
||||
if(nViewports && nViewports > 1) {
|
||||
const viewer = $('#viewer');
|
||||
const leftSidebar = viewer.find('.sidebar-left.sidebar-open');
|
||||
const rightSidebar = viewer.find('.sidebar-right.sidebar-open');
|
||||
|
||||
const leftQuickSwitch = $('.quickSwitchWrapper.left');
|
||||
const rightQuickSwitch = $('.quickSwitchWrapper.right');
|
||||
|
||||
const hasLeftSidebar = leftSidebar.length > 0;
|
||||
const hasRightSidebar = rightSidebar.length > 0;
|
||||
|
||||
rightQuickSwitch.removeClass('left-sidebar-only');
|
||||
leftQuickSwitch.removeClass('right-sidebar-only');
|
||||
|
||||
let leftOffset = 0;
|
||||
|
||||
if(hasLeftSidebar) {
|
||||
leftOffset = ( leftSidebar.width()/$(window).width() ) * 100;
|
||||
|
||||
if(!hasRightSidebar) {
|
||||
rightQuickSwitch.addClass('left-sidebar-only');
|
||||
}
|
||||
}
|
||||
|
||||
if(hasRightSidebar && !hasLeftSidebar) {
|
||||
leftQuickSwitch.addClass('right-sidebar-only');
|
||||
}
|
||||
|
||||
const leftPosition = ( ($('#imageViewerViewports').width() / nViewports) / $(window).width() ) * 100 + leftOffset;
|
||||
const rightPosition = 100 - leftPosition;
|
||||
|
||||
leftQuickSwitch.css('right', rightPosition + '%');
|
||||
rightQuickSwitch.css('left', leftPosition + '%');
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate dialogs positions
|
||||
relocateDialogs(){
|
||||
OHIF.log.info('ResizeViewportManager relocateDialogs');
|
||||
|
||||
const bottomRightDialogs = $('#cineDialog, #annotationDialog, #textMarkerOptionsDialog');
|
||||
bottomRightDialogs.css({
|
||||
top: '', // This removes the CSS property completely
|
||||
left: '',
|
||||
bottom: 0,
|
||||
right: 0
|
||||
});
|
||||
|
||||
const centerDialogs = $('.draggableDialog').not(bottomRightDialogs);
|
||||
|
||||
centerDialogs.css({
|
||||
top: 0,
|
||||
left:0,
|
||||
bottom: 0,
|
||||
right: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Resize viewport scrollbars
|
||||
resizeScrollbars(element) {
|
||||
OHIF.log.info('ResizeViewportManager resizeScrollbars');
|
||||
|
||||
const currentOverlay = $(element).siblings('.imageViewerViewportOverlay');
|
||||
const imageControls = currentOverlay.find('.imageControls');
|
||||
currentOverlay.find('.imageControls').height($(element).height());
|
||||
|
||||
// Set it's width to its parent's height
|
||||
// (because webkit is stupid and can't style vertical sliders)
|
||||
const scrollbar = currentOverlay.find('#scrollbar');
|
||||
scrollbar.height(scrollbar.parent().height() - 20);
|
||||
|
||||
const currentImageSlider = currentOverlay.find('#imageSlider');
|
||||
const overlayHeight = currentImageSlider.parent().height();
|
||||
const browserInfo = cornerstoneTools.getBrowserInfo();
|
||||
|
||||
if (browserInfo.indexOf('IE') > -1) {
|
||||
currentImageSlider.height(overlayHeight);
|
||||
} else {
|
||||
currentImageSlider.width(overlayHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize a single viewport element
|
||||
resizeViewportElement(element) {
|
||||
let enabledElement;
|
||||
try {
|
||||
enabledElement = cornerstone.getEnabledElement(element);
|
||||
} catch(error) {
|
||||
return;
|
||||
}
|
||||
|
||||
cornerstone.resize(element, true);
|
||||
|
||||
if (enabledElement.fitToWindow === false) {
|
||||
const imageId = enabledElement.image.imageId;
|
||||
const instance = cornerstoneTools.metaData.get('instance', imageId);
|
||||
const instanceClassViewport = getInstanceClassDefaultViewport(instance, enabledElement, imageId);
|
||||
cornerstone.setViewport(element, instanceClassViewport);
|
||||
}
|
||||
}
|
||||
|
||||
// Resize each viewport element
|
||||
resizeViewportElements() {
|
||||
this.relocateDialogs();
|
||||
|
||||
const viewportResizeTimer = setTimeout(() => {
|
||||
this.repositionStudySeriesQuickSwitch();
|
||||
|
||||
const elements = $('.imageViewerViewport').not('.empty');
|
||||
elements.each((index, element) => {
|
||||
this.resizeViewportElement(element);
|
||||
this.resizeScrollbars(element);
|
||||
});
|
||||
}, 1);
|
||||
}
|
||||
|
||||
// Function to override resizeViewportElements function
|
||||
setResizeViewportElement(resizeViewportElements) {
|
||||
this.resizeViewportElements = resizeViewportElements;
|
||||
}
|
||||
|
||||
// Avoid doing DOM manipulation during the resize handler
|
||||
// because it is fired very often.
|
||||
// Resizing is therefore performed 100 ms after the resize event stops.
|
||||
handleResize() {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
OHIF.log.info('ResizeViewportManager resizeViewportElements');
|
||||
this.resizeViewportElements();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
|
||||
const FUNCTION = 'function';
|
||||
const OBJECT = 'object';
|
||||
const STRING = 'string';
|
||||
const PROPERTY_SEPARATOR = '.';
|
||||
const ORDER_ASC = 'asc';
|
||||
const ORDER_DESC = 'desc';
|
||||
const MIN_COUNT = 0x00000000;
|
||||
const MAX_COUNT = 0x7FFFFFFF;
|
||||
|
||||
export class TypeSafeCollection {
|
||||
|
||||
constructor() {
|
||||
this._operationCount = new ReactiveVar(MIN_COUNT);
|
||||
this._elementList = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Private Methods
|
||||
*/
|
||||
|
||||
_invalidate() {
|
||||
let count = this._operationCount.get();
|
||||
this._operationCount.set(count < MAX_COUNT ? count + 1 : MIN_COUNT);
|
||||
}
|
||||
|
||||
_elements(silent) {
|
||||
(silent === true || this._operationCount.get());
|
||||
return this._elementList;
|
||||
}
|
||||
|
||||
_elementWithPayload(payload, silent) {
|
||||
return this._elements(silent).find(item => item.payload === payload);
|
||||
}
|
||||
|
||||
_elementWithId(id, silent) {
|
||||
return this._elements(silent).find(item => item.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieve an object's property value by name
|
||||
* @param {Object} object The object we want read the property from...
|
||||
* @param {String} propertyName The property to be read (e.g., 'address.street.name' or 'address.street.number'
|
||||
* to read object.address.street.name or object.address.street.number);
|
||||
* @returns {Any} Returns whatever the property holds or undefined if the property cannot be read or reached.
|
||||
*/
|
||||
static getPropertyValue(object, propertyName) {
|
||||
let propertyValue;
|
||||
if (typeof object === OBJECT && object !== null && typeof propertyName === STRING) {
|
||||
let fragments = propertyName.split(PROPERTY_SEPARATOR),
|
||||
fragmentCount = fragments.length;
|
||||
if (fragmentCount > 0) {
|
||||
let firstFragment = fragments[0],
|
||||
remainingFragments = fragmentCount > 1 ? fragments.slice(1).join(PROPERTY_SEPARATOR) : null;
|
||||
propertyValue = object[firstFragment];
|
||||
if (remainingFragments !== null) {
|
||||
propertyValue = TypeSafeCollection.getPropertyValue(propertyValue, remainingFragments);
|
||||
}
|
||||
}
|
||||
}
|
||||
return propertyValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a sorting specifier is valid.
|
||||
* A valid sorting specifier consists of an array of arrays being each subarray a pair
|
||||
* in the format ["property name", "sorting order"].
|
||||
* The following exemple can be used to sort studies by "date"" and use "time" to break ties in descending order.
|
||||
* [ [ 'study.date', 'desc' ], [ 'study.time', 'desc' ] ]
|
||||
* @param {Array} specifiers The sorting specifier to be tested.
|
||||
* @returns {boolean} Returns true if the specifiers are valid, false otherwise.
|
||||
*/
|
||||
static isValidSortingSpecifier(specifiers) {
|
||||
let result = true;
|
||||
if (specifiers instanceof Array && specifiers.length > 0) {
|
||||
for (let i = specifiers.length - 1; i >= 0; i--) {
|
||||
const item = specifiers[i];
|
||||
if (item instanceof Array) {
|
||||
const property = item[0];
|
||||
const order = item[1];
|
||||
if (typeof property === STRING && (order === ORDER_ASC || order === ORDER_DESC)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts an array based on sorting specifier options.
|
||||
* @param {Array} list The that needs to be sorted.
|
||||
* @param {Array} specifiers An array of specifiers. Please read isValidSortingSpecifier method definition for further details.
|
||||
* @returns {void} No value is returned. The array is sorted in place.
|
||||
*/
|
||||
static sortListBy(list, specifiers) {
|
||||
const thisClass = TypeSafeCollection;
|
||||
if (list instanceof Array && thisClass.isValidSortingSpecifier(specifiers)) {
|
||||
const getPropertyValue = thisClass.getPropertyValue;
|
||||
const specifierCount = specifiers.length;
|
||||
list.sort((a, b) => {
|
||||
let index = 0;
|
||||
while (index < specifierCount) {
|
||||
const specifier = specifiers[index];
|
||||
const property = specifier[0];
|
||||
const order = specifier[1] === ORDER_DESC ? -1 : 1;
|
||||
const aValue = getPropertyValue(a, property);
|
||||
const bValue = getPropertyValue(b, property);
|
||||
// @TODO: should we check for the types being compared, like:
|
||||
// ~~ if (typeof aValue !== typeof bValue) continue;
|
||||
// Not sure because dates, for example, can be correctly compared to numbers...
|
||||
if (aValue < bValue) {
|
||||
return order * -1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return order * 1;
|
||||
}
|
||||
if (++index >= specifierCount) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('TypeSafeCollection::sortListBy Invalid Arguments');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Methods
|
||||
*/
|
||||
|
||||
updateById(id, payload) {
|
||||
let result = false,
|
||||
found = this._elementWithPayload(payload, true);
|
||||
if (found) {
|
||||
// nothing to do since the element is already in the collection...
|
||||
if (found.id === id) {
|
||||
// set result to true since the ids match...
|
||||
result = true;
|
||||
this._invalidate();
|
||||
}
|
||||
} else {
|
||||
found = this._elementWithId(id, true);
|
||||
if (found) {
|
||||
found.payload = payload;
|
||||
result = true;
|
||||
this._invalidate();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
let result = false,
|
||||
found = this._elementWithPayload(payload, true);
|
||||
if (found) {
|
||||
// nothing to do since the element is already in the collection...
|
||||
result = true;
|
||||
this._invalidate();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
insert(payload) {
|
||||
let id = null,
|
||||
found = this._elementWithPayload(payload, true);
|
||||
if (!found) {
|
||||
id = Random.id();
|
||||
this._elements(true).push({ id, payload });
|
||||
this._invalidate();
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
removeAll() {
|
||||
let all = this._elements(true),
|
||||
length = all.length;
|
||||
for (let i = length - 1; i >= 0; i--) {
|
||||
let item = all[i];
|
||||
delete item.id;
|
||||
delete item.payload;
|
||||
all[i] = null;
|
||||
}
|
||||
all.splice(0, length);
|
||||
this._invalidate();
|
||||
}
|
||||
|
||||
remove(propertyMap) {
|
||||
let found = this.findAllBy(propertyMap),
|
||||
foundCount = found.length,
|
||||
removed = [];
|
||||
if (foundCount > 0) {
|
||||
const all = this._elements(true);
|
||||
for (let i = foundCount - 1; i >= 0; i--) {
|
||||
let item = found[i];
|
||||
all.splice(item[2], 1);
|
||||
removed.push(item[0]);
|
||||
}
|
||||
this._invalidate();
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
getElementId(payload) {
|
||||
let found = this._elementWithPayload(payload);
|
||||
return found && found.id;
|
||||
}
|
||||
|
||||
findById(id) {
|
||||
let found = this._elementWithId(id);
|
||||
return found && found.payload;
|
||||
}
|
||||
|
||||
indexOfElement(payload) {
|
||||
return this._elements().indexOf(this._elementWithPayload(payload, true));
|
||||
}
|
||||
|
||||
indexOfId(id) {
|
||||
return this._elements().indexOf(this._elementWithId(id, true));
|
||||
}
|
||||
|
||||
getElementByIndex(index) {
|
||||
let found = ((this._elements())[index >= 0 ? index : -1]);
|
||||
return found && found.payload;
|
||||
}
|
||||
|
||||
// the reactive var will not be notified if no valid callback is supplied...
|
||||
find(callback) {
|
||||
let found;
|
||||
if (typeof callback === FUNCTION) {
|
||||
found = this._elements().find((item, index) => {
|
||||
return callback.call(this, item.payload, index);
|
||||
});
|
||||
}
|
||||
return found && found.payload;
|
||||
}
|
||||
|
||||
// the reactive var will not be notified if no valid property map is supplied...
|
||||
findBy(propertyMap) {
|
||||
let found;
|
||||
if (typeof propertyMap === OBJECT && propertyMap !== null) {
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
found = this._elements().find(item => {
|
||||
const payload = item.payload;
|
||||
for (let propertyName in propertyMap) {
|
||||
if (hasOwn.call(propertyMap, propertyName)) {
|
||||
if (propertyMap[propertyName] !== TypeSafeCollection.getPropertyValue(payload, propertyName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return found && found.payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all elements that match a specified property map.
|
||||
* Attention!!! The reactive source will not be notified if no valid property map is supplied...
|
||||
* @param {Object} propertyMap A property map that will be macthed against all collection elements.
|
||||
* @param {Object} options A set of options. Currently only "options.sort" option is supported.
|
||||
* @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied
|
||||
* but it's not valid, an exception will be thrown.
|
||||
* @returns {Array} An array with all elements stored in the collection.
|
||||
*/
|
||||
findAllBy(propertyMap, options) {
|
||||
let found = [];
|
||||
if (typeof propertyMap === OBJECT && propertyMap !== null) {
|
||||
const hasOwn = Object.prototype.hasOwnProperty;
|
||||
this._elements().forEach((item, index) => {
|
||||
const payload = item.payload;
|
||||
for (let propertyName in propertyMap) {
|
||||
if (hasOwn.call(propertyMap, propertyName)) {
|
||||
if (propertyMap[propertyName] !== TypeSafeCollection.getPropertyValue(payload, propertyName)) {
|
||||
return; // skip this element since it does not match the criteria...
|
||||
}
|
||||
}
|
||||
}
|
||||
// Match! Add it to the found list...
|
||||
found.push([ payload, item.id, index ]);
|
||||
});
|
||||
}
|
||||
if (options instanceof Object && 'sort' in options) {
|
||||
TypeSafeCollection.sortListBy(found, options.sort);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// the reactive var will not be notified if no valid callback is supplied...
|
||||
forEach(callback) {
|
||||
if (typeof callback === FUNCTION) {
|
||||
this._elements().forEach((item, index) => {
|
||||
callback.call(this, item.payload, item.id, index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of elements currently in the collection.
|
||||
* @returns {number} The current number of elements in the collection.
|
||||
*/
|
||||
count() {
|
||||
return this._elements().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list with all elements of the collection optionally sorted by a sorting specifier criteria.
|
||||
* @param {Object} options A set of options. Currently only "options.sort" option is supported.
|
||||
* @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied
|
||||
* but it's not valid, an exception will be thrown.
|
||||
* @returns {Array} An array with all elements stored in the collection.
|
||||
*/
|
||||
all(options) {
|
||||
let list = this._elements().map(item => item.payload);
|
||||
if (options instanceof Object && 'sort' in options) {
|
||||
TypeSafeCollection.sortListBy(list, options.sort);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Metadata } from './Metadata';
|
||||
|
||||
const UNDEFINED = 'undefined';
|
||||
const NUMBER = 'number';
|
||||
const STRING = 'string';
|
||||
const REGEX_TAG = /^x[0-9a-f]{8}$/;
|
||||
|
||||
export class InstanceMetadata extends Metadata {
|
||||
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this._sopInstanceUID = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SOPInstanceUID of the current instance.
|
||||
*/
|
||||
getSOPInstanceUID() {
|
||||
return this._sopInstanceUID;
|
||||
}
|
||||
|
||||
// @TODO: Improve this... (E.g.: blob data)
|
||||
getStringValue(tagOrProperty, index, defaultValue) {
|
||||
let value = this.getRawValue(tagOrProperty, defaultValue);
|
||||
|
||||
if (typeof value !== STRING && typeof value !== UNDEFINED) {
|
||||
value = value.toString();
|
||||
}
|
||||
|
||||
return InstanceMetadata.getIndexedValue(value, index, defaultValue);
|
||||
}
|
||||
|
||||
// @TODO: Improve this... (E.g.: blob data)
|
||||
getFloatValue(tagOrProperty, index, defaultValue) {
|
||||
let value = this.getRawValue(tagOrProperty, defaultValue);
|
||||
value = InstanceMetadata.getIndexedValue(value, index, defaultValue);
|
||||
|
||||
if(value instanceof Array) {
|
||||
value.forEach( (val, idx) => {
|
||||
value[idx] = parseFloat(val);
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return typeof value === STRING ? parseFloat(value) : value;
|
||||
}
|
||||
|
||||
// @TODO: Improve this... (E.g.: blob data)
|
||||
getIntValue(tagOrProperty, index, defaultValue) {
|
||||
let value = this.getRawValue(tagOrProperty, defaultValue);
|
||||
value = InstanceMetadata.getIndexedValue(value, index, defaultValue);
|
||||
|
||||
if(value instanceof Array) {
|
||||
value.forEach( (val, idx) => {
|
||||
value[idx] = parseFloat(val);
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return typeof value === STRING ? parseInt(value) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
getRawValue(tagOrProperty, defaultValue) {
|
||||
/**
|
||||
* Please override this method on a specialized class.
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current instance with another one.
|
||||
* @param {InstanceMetadata} instance An instance of the InstanceMetadata class.
|
||||
* @returns {boolean} Returns true if both instances refer to the same instance.
|
||||
*/
|
||||
equals(instance) {
|
||||
const self = this;
|
||||
return (
|
||||
instance === self ||
|
||||
(
|
||||
instance instanceof InstanceMetadata &&
|
||||
instance.getSOPInstanceUID() === self.getSOPInstanceUID()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the tagOrProperty exists
|
||||
* @param {String} tagOrProperty tag or property be checked
|
||||
* @return {Boolean} True if the tag or property exists or false if doesn't
|
||||
*/
|
||||
tagExists(tagOrProperty) {
|
||||
/**
|
||||
* Please override this method
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URI for the specified resource related to this instance (if an instance).
|
||||
* @param {string} resource The name of the resource. This name cannot have any leading or
|
||||
* trailing slashes
|
||||
* @param {string|number} [optParameter] An optional parameter. This cannot have any leading or
|
||||
* trailing slashes
|
||||
* @returns {string|undefined} The constructed URI for the specified resource related to this
|
||||
* instance or undefined if this sequence is a sub-sequence (i.e.: it has no related resources of
|
||||
* its own).
|
||||
*/
|
||||
getResourceUri(resource, optParameter) {
|
||||
/**
|
||||
* Please override this method
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Methods
|
||||
*/
|
||||
|
||||
static getTagInfo(tagOrProperty) {
|
||||
let tagName = null,
|
||||
propertyName = null;
|
||||
|
||||
if (typeof tagOrProperty === NUMBER) {
|
||||
// if it's a number, build an hexadecimal representation...
|
||||
tagName = 'x' + ('00000000' + tagOrProperty.toString(16)).substr(-8);
|
||||
} else if (typeof tagOrProperty === STRING && REGEX_TAG.test(tagOrProperty)) {
|
||||
tagName = tagOrProperty;
|
||||
} else {
|
||||
// use it as a property name otherwise...
|
||||
propertyName = tagOrProperty;
|
||||
}
|
||||
|
||||
// @TODO: map tagName to propertyName and vice versa
|
||||
|
||||
return { tagName, propertyName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an value based that can be index based. This function is called by all getters. See above functions.
|
||||
* - If value is a String and has indexes:
|
||||
* - If undefined index: returns an array of the split values.
|
||||
* - If defined index:
|
||||
* - If invalid: returns defaultValue
|
||||
* - If valid: returns the indexed value
|
||||
* - If value is not a String, returns default value.
|
||||
*/
|
||||
static getIndexedValue(value, index, defaultValue) {
|
||||
let result = defaultValue;
|
||||
|
||||
if (typeof value === STRING) {
|
||||
const hasIndexValues = value.indexOf('\\') !== -1;
|
||||
|
||||
result = value;
|
||||
|
||||
if(hasIndexValues) {
|
||||
const splitValues = value.split('\\');
|
||||
if (Metadata.isValidIndex(index)) {
|
||||
const indexedValue = splitValues[index];
|
||||
|
||||
result = typeof indexedValue !== STRING ? defaultValue : indexedValue;
|
||||
}
|
||||
else {
|
||||
result = splitValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
const STRING = 'string';
|
||||
const NUMBER = 'number';
|
||||
const FUNCTION = 'function';
|
||||
|
||||
export class Metadata {
|
||||
|
||||
/**
|
||||
* Constructor and Instance Methods
|
||||
*/
|
||||
|
||||
constructor(data) {
|
||||
this._data = data;
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this._data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Methods
|
||||
*/
|
||||
|
||||
static isValidUID(uid) {
|
||||
return typeof uid === STRING && uid.length > 0;
|
||||
}
|
||||
|
||||
static isValidIndex(index) {
|
||||
return typeof index === NUMBER && index >= 0 && (index | 0) === index;
|
||||
}
|
||||
|
||||
static isValidCallback(callback) {
|
||||
return typeof callback === FUNCTION;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
# Study Metadata Module
|
||||
|
||||
This module defines the API/Data-Model by which OHIF Viewerbase package and possibly distinct viewer
|
||||
implementations can access studies metadata. This module does not attempt to define any means of
|
||||
*loading* study metadata from any data end-point but only how the data that has been previously
|
||||
loaded into the application context will be accessed by any of the routines or algorithm implementations
|
||||
that need the data.
|
||||
|
||||
## Intro
|
||||
|
||||
For various reasons like sorting, grouping or simply rendering study information, OHIF Viewerbase package
|
||||
and applications depending on it usualy have the need to access study metadata. Before the current
|
||||
initiative there was no uniform way of achieving that since each implementation provides study metadata
|
||||
on its own specific ways. The application and the package itself needed to have a deep knowledge of the
|
||||
data structures provided by the data endpoint to perform any of the operations mentioned above, meaning
|
||||
that any data access code needed to be adapted or rewritten.
|
||||
|
||||
The intent of the current module is to provide a fairly consistent and flexible API/Data-Model by which
|
||||
OHIF Viewerbase package (and different viewer implementations that depend on it) can manipulate DICOM matadata
|
||||
retrieved from distinct data end points (e.g., a proprietary back end servers) in uniform ways with minor
|
||||
to no modifications needed.
|
||||
|
||||
## Implementation
|
||||
|
||||
The current API implementation defines three classes of objects: `StudyMetadata`, `SeriesMetadata`
|
||||
and `InstanceMetadata`. Inside OHIF Viewerbase package, every access to Study, Series or SOP Instance
|
||||
metadata is achieved by the interface exposed by these three classes. By inheriting from them and
|
||||
overriding or extending their methods, different applications with different data models can adapt
|
||||
even the most peculiar data structures to the uniform interface defined by those classes. Together
|
||||
these classes define a flexible and extensible data manipulation layer leaving routines and
|
||||
algorithms that depend on that data untouched.
|
||||
|
||||
## Design Decisions & "*Protected*" Members
|
||||
|
||||
In order to provide for good programming practices, attributes and methods meant to be used exclusevily by
|
||||
the classes themselves (for internal purposes only) were written with an initial '_' character, being thus treated
|
||||
as "*protected*" members. The idea behind this practice was never to hide them from the programmers
|
||||
(what makes debugging tasks painful) but only advise for something that's not part of the official public API
|
||||
and thus should not be relied on. Usage of "protected" members makes the code less readable and prone to
|
||||
compatibility issues.
|
||||
|
||||
As an example, the initial implementation of the `StudyMetadata` class defined the attribute `_studyInstanceUID`
|
||||
and the method `getStudyInstanceUID`. This implies that whenever the *StudyInstanceUID* of a given study needs
|
||||
to be retrieved the `getStudyInstanceUID` method should be called instead of directly accessing the
|
||||
attribute `_studyInstanceUID` (which might not even be populated since `getStudyInstanceUID` can be possiblity
|
||||
overriden by a subclass to satisfy specific implementation needs, leaving the attribute `_studyInstanceUID` unused).
|
||||
|
||||
Ex:
|
||||
|
||||
```javascript
|
||||
let studyUID = myStudy.getStudyInstanceUID(); // GOOD! :-)
|
||||
[ ... ]
|
||||
let otherStudyUID = anotherStudy._studyInstanceUID; // BAD... :-(
|
||||
```
|
||||
|
||||
Another important topic is the preference of *methods* over *attributes* on the public API. This design
|
||||
decision was made to ensure extensibility and flexibility (methods are extensible while standalone
|
||||
attributes are not, and can be adapted – through overrides, for example – to support even the most
|
||||
peculiar data models) even though the overhead a few additional function calls may incur.
|
||||
|
||||
## Abstract Classes
|
||||
|
||||
Some classes defined in this module are "*abstract*" classes (even though JavaScript does not *officially*
|
||||
support such programming facility). They are *abstract* in the sense that a few methods (very important ones,
|
||||
by the way) were left "*blank*" (unimplemented, or more precisely implemented as empty NOP functions) in
|
||||
order to be implemented by specialized subclasses. Methods believed to be more generic were implemented in
|
||||
an attempt to satify most implementation needs but nothing prevents a subclass from overriding them as well
|
||||
(again, flexibility and extensibility are design goals). Most implemented methods rely on the implementation
|
||||
of an unimplemented method. For example, the method `getStringValue` from `InstanceMetadata` class, which
|
||||
has indeed been implemented and is meant to retrieve a metadata value as a string, internally calls the
|
||||
`getRawValue` method which *was NOT implemented* and is meant to query the internal data structures for the
|
||||
requested metadata value and return it *as is*. Used in that way, an application would not benefit much
|
||||
from the already implemented methods. On the other hand, by simply overriding the `getRawValue` method
|
||||
on a specialized class to deal with the intrinsics of its internal data structures, this very application
|
||||
would now benefit from all already implemented methods.
|
||||
|
||||
The following code snippet tries to illustrate the idea:
|
||||
|
||||
```javascript
|
||||
|
||||
// -- InstanceMetadata.js
|
||||
|
||||
class InstanceMetadata {
|
||||
[ ... ]
|
||||
getRawValue(tagOrProperty, defaultValue) {
|
||||
// Please implement this method in a specialized subclass...
|
||||
}
|
||||
[ ... ]
|
||||
getStringValue(tagOrProperty, index, defaultValue) {
|
||||
let rawValue = this.getRawValue(tagOrProperty, '');
|
||||
// parse the returned value into a string...
|
||||
[ ... ]
|
||||
return stringValue;
|
||||
}
|
||||
[ ... ]
|
||||
}
|
||||
|
||||
// -- MyFancyAppInstanceMetadata.js
|
||||
|
||||
class MyFancyAppInstanceMetadata extends InstanceMetadata {
|
||||
// Overriding this method will make all methods implemented in the super class
|
||||
// that rely on it to be immediately available...
|
||||
getRawValue(tagOrProperty, defaultValue) {
|
||||
let rawValue;
|
||||
// retrieve raw value from internal data structures...
|
||||
[ ... ]
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
// -- main.js
|
||||
|
||||
[ ... ]
|
||||
let sopInstaceMetadata = new MyFancyAppInstanceMetadata(myInternalData);
|
||||
if (sopInstaceMetadata instanceof MyFancyAppInstanceMetadata) { // true
|
||||
// this code will be executed...
|
||||
}
|
||||
if (sopInstaceMetadata instanceof InstanceMetadata) { // also true
|
||||
// this code will also be executed...
|
||||
}
|
||||
// The following will also work since the internal "getRawValue" call inside
|
||||
// "getStringValue" method will now be satisfied... (thanks to the override)
|
||||
let patientName = sopInstaceMetadata.getStringValue('PatientName', '');
|
||||
[ ... ]
|
||||
|
||||
```
|
||||
|
||||
_Copyright © 2016 nucleushealth™. All rights reserved_
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Metadata } from './Metadata';
|
||||
import { InstanceMetadata } from './InstanceMetadata';
|
||||
|
||||
export class SeriesMetadata extends Metadata {
|
||||
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this._seriesInstanceUID = null;
|
||||
this._instances = []; // InstanceMetadata[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SeriesInstanceUID of the current series.
|
||||
*/
|
||||
getSeriesInstanceUID() {
|
||||
return this._seriesInstanceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an instance to the current series.
|
||||
* @param {InstanceMetadata} instance The instance to be added to the current series.
|
||||
* @returns {boolean} Returns true on success, false otherwise.
|
||||
*/
|
||||
addInstance(instance) {
|
||||
let result = false;
|
||||
if (instance instanceof InstanceMetadata && this.getInstanceByUID(instance.getSOPInstanceUID()) === void 0) {
|
||||
this._instances.push(instance);
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an instance by index.
|
||||
* @param {number} index An integer representing a list index.
|
||||
* @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise.
|
||||
*/
|
||||
getInstanceByIndex(index) {
|
||||
let found; // undefined by default...
|
||||
if (Metadata.isValidIndex(index)) {
|
||||
found = this._instances[index];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an instance by SOPInstanceUID.
|
||||
* @param {string} uid An UID string.
|
||||
* @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise.
|
||||
*/
|
||||
getInstanceByUID(uid) {
|
||||
let found; // undefined by default...
|
||||
if (Metadata.isValidUID(uid)) {
|
||||
found = this._instances.find(instance => {
|
||||
return instance.getSOPInstanceUID() === uid;
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the number of instances within the current series.
|
||||
* @returns {number} The number of instances in the current series.
|
||||
*/
|
||||
getInstanceCount() {
|
||||
return this._instances.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the supplied callback for each instance in the current series passing
|
||||
* two arguments: instance (an InstanceMetadata instance) and index (the integer
|
||||
* index of the instance within the current series)
|
||||
* @param {function} callback The callback function which will be invoked for each instance in the series.
|
||||
* @returns {undefined} Nothing is returned.
|
||||
*/
|
||||
forEachInstance(callback) {
|
||||
if (Metadata.isValidCallback(callback)) {
|
||||
this._instances.forEach((instance, index) => {
|
||||
callback.call(null, instance, index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of an instance inside the series.
|
||||
* @param {InstanceMetadata} instance An instance of the SeriesMetadata class.
|
||||
* @returns {number} The index of the instance inside the series or -1 if not found.
|
||||
*/
|
||||
indexOfInstance(instance) {
|
||||
return this._instances.indexOf(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current series with another one.
|
||||
* @param {SeriesMetadata} series An instance of the SeriesMetadata class.
|
||||
* @returns {boolean} Returns true if both instances refer to the same series.
|
||||
*/
|
||||
equals(series) {
|
||||
const self = this;
|
||||
return (
|
||||
series === self ||
|
||||
(
|
||||
series instanceof SeriesMetadata &&
|
||||
series.getSeriesInstanceUID() === self.getSeriesInstanceUID()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Metadata } from './Metadata';
|
||||
import { SeriesMetadata } from './SeriesMetadata';
|
||||
|
||||
export class StudyMetadata extends Metadata {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
this._studyInstanceUID = null;
|
||||
this._series = []; // SeriesMetadata[]
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the StudyInstanceUID of the current study.
|
||||
*/
|
||||
getStudyInstanceUID() {
|
||||
return this._studyInstanceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a series to the current study.
|
||||
* @param {SeriesMetadata} series The series to be added to the current study.
|
||||
* @returns {boolean} Returns true on success, false otherwise.
|
||||
*/
|
||||
addSeries(series) {
|
||||
let result = false;
|
||||
if (series instanceof SeriesMetadata && this.getSeriesByUID(series.getSeriesInstanceUID()) === void 0) {
|
||||
this._series.push(series);
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a series by index.
|
||||
* @param {number} index An integer representing a list index.
|
||||
* @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise.
|
||||
*/
|
||||
getSeriesByIndex(index) {
|
||||
let found; // undefined by default...
|
||||
if (Metadata.isValidIndex(index)) {
|
||||
found = this._series[index];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a series by SeriesInstanceUID.
|
||||
* @param {string} uid An UID string.
|
||||
* @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise.
|
||||
*/
|
||||
getSeriesByUID(uid) {
|
||||
let found; // undefined by default...
|
||||
if (Metadata.isValidUID(uid)) {
|
||||
found = this._series.find(series => {
|
||||
return series.getSeriesInstanceUID() === uid;
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the number of series within the current study.
|
||||
* @returns {number} The number of series in the current study.
|
||||
*/
|
||||
getSeriesCount() {
|
||||
return this._series.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the number of instances within the current study.
|
||||
* @returns {number} The number of instances in the current study.
|
||||
*/
|
||||
getInstanceCount() {
|
||||
return this._series.reduce((sum, series) => {
|
||||
return sum + series.getInstanceCount();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the supplied callback for each series in the current study passing
|
||||
* two arguments: series (a SeriesMetadata instance) and index (the integer
|
||||
* index of the series within the current study)
|
||||
* @param {function} callback The callback function which will be invoked for each series instance.
|
||||
* @returns {undefined} Nothing is returned.
|
||||
*/
|
||||
forEachSeries(callback) {
|
||||
if (Metadata.isValidCallback(callback)) {
|
||||
this._series.forEach((series, index) => {
|
||||
callback.call(null, series, index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of a series inside the study.
|
||||
* @param {SeriesMetadata} series An instance of the SeriesMetadata class.
|
||||
* @returns {number} The index of the series inside the study or -1 if not found.
|
||||
*/
|
||||
indexOfSeries(series) {
|
||||
return this._series.indexOf(series);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the current study instance with another one.
|
||||
* @param {StudyMetadata} study An instance of the StudyMetadata class.
|
||||
* @returns {boolean} Returns true if both instances refer to the same study.
|
||||
*/
|
||||
equals(study) {
|
||||
const self = this;
|
||||
return (
|
||||
study === self ||
|
||||
(
|
||||
study instanceof StudyMetadata &&
|
||||
study.getStudyInstanceUID() === self.getStudyInstanceUID()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first instance of the first series
|
||||
* @return {InstanceMetadata} InstanceMetadata class object or undefined if it doenst exist
|
||||
*/
|
||||
getFirstInstance() {
|
||||
let firstInstance;
|
||||
const firstSeries = this.getSeriesByIndex(0);
|
||||
|
||||
if (firstSeries) {
|
||||
firstInstance = firstSeries.getInstanceByIndex(0);
|
||||
}
|
||||
|
||||
return firstInstance;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user