define([
'three',
'orbitcontrols',
'draw',
'underscore',
'selectionbox',
'selectionhelper'
], function(THREE, OrbitControls, draw, _, SelectionBox, SelectionHelper) {
/** @private */
var makeLine = draw.makeLine;
/** @private */
var makeLabel = draw.makeLabel;
/**
*
* @class ScenePlotView3D
*
* Represents a three dimensional scene in THREE.js.
*
* @param {UIState} uiState shared UIState state object
* @param {THREE.renderer} renderer THREE renderer object.
* @param {Object} decViews dictionary of DecompositionViews shown in this
* scene
* @param {MultiModel} decModels MultiModel of DecompositionModels shown in
* this scene (with extra global data about them)
* @param {Node} container Div where the scene will be rendered.
* @param {Float} xView Horizontal position of the rendered scene in the
* container element.
* @param {Float} yView Vertical position of the rendered scene in the
* container element.
* @param {Float} width The width of the renderer
* @param {Float} height The height of the renderer
*
* @return {ScenePlotView3D} An instance of ScenePlotView3D.
* @constructs ScenePlotView3D
*/
function ScenePlotView3D(uiState, renderer, decViews, decModels, container,
xView, yView, width, height) {
var scope = this;
this.UIState = uiState;
// convert to jquery object for consistency with the rest of the objects
var $container = $(container);
this.decViews = decViews;
this.decModels = decModels;
this.renderer = renderer;
/**
* Horizontal position of the scene.
* @type {Float}
*/
this.xView = xView;
/**
* Vertical position of the scene.
* @type {Float}
*/
this.yView = yView;
/**
* Width of the scene.
* @type {Float}
*/
this.width = width;
/**
* Height of the scene.
* @type {Float}
*/
this.height = height;
/**
* Axes color.
* @type {String}
* @default '#FFFFFF' (white)
*/
this.axesColor = '#FFFFFF';
/**
* Background color.
* @type {String}
* @default '#000000' (black)
*/
this.backgroundColor = '#000000';
/**
* True when changes have occured that require re-rendering of the canvas
* @type {Boolean}
*/
this.needsUpdate = true;
/**
* Array of integers indicating the index of the visible dimension at each
* axis ([x, y, z]).
* @type {Integer[]}
*/
this.visibleDimensions = _.clone(this.decViews.scatter.visibleDimensions);
// used to name the axis lines/labels in the scene
this._axisPrefix = 'emperor-axis-line-';
this._axisLabelPrefix = 'emperor-axis-label-';
//need to initialize the scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(this.backgroundColor);
/**
* Camera used to display the scatter scene.
* @type {THREE.OrthographicCamera}
*/
this.scatterCam = this.buildCamera('scatter');
/**
* Object used to light the scene in scatter mode,
* by default is set to a light and
* transparent color (0x99999999).
* @type {THREE.DirectionalLight}
*/
this.light = new THREE.DirectionalLight(0x999999, 2);
this.light.position.set(1, 1, 1).normalize();
this.scatterCam.add(this.light);
/**
* Camera used to display the parallel plot scene.
* @type {THREE.OrthographicCamera}
*/
this.parallelCam = this.buildCamera('parallel-plot');
// use $container.get(0) to retrieve the native DOM object
this.scatterController = this.buildCamController('scatter',
this.scatterCam,
$container.get(0));
this.parallelController = this.buildCamController('parallel-plot',
this.parallelCam,
$container.get(0));
this.control = this.scatterController;
this.scene.add(this.scatterCam);
this.scene.add(this.parallelCam);
this._raycaster = new THREE.Raycaster();
this._mouse = new THREE.Vector2();
/**
* Special purpose group for points that are selectable with the
* SelectionBox.
* @type {THREE.Group}
* @private
*/
this._selectable = new THREE.Group();
this.scene.add(this._selectable);
/**
* Object to compute bounding boxes from a selection area
*
* Selection is only enabled when the user is holding Shift.
*
* @type {THREE.SelectionBox}
* @private
*/
this._selectionBox = new THREE.SelectionBox(this.camera,
this._selectable);
/**
* Helper to view the selection space when the user holds shift
*
* This object is disabled by default, and is only renabled when the user
* holds the shift key.
* @type {THREE.SelectionHelper}
* @private
*/
this._selectionHelper = new THREE.SelectionHelper(this._selectionBox,
renderer,
'emperor-selection-area');
this._selectionHelper.enabled = false;
//Swap the camera whenever the view type changes
this.UIState.registerProperty('view.viewType', function(evt) {
if (evt.newVal === 'parallel-plot') {
scope.camera = scope.parallelCam;
scope.control = scope.parallelController;
//Don't let the controller move around when its not the active camera
scope.scatterController.enabled = false;
scope.scatterController.autoRotate = false;
scope.parallelController.enabled = true;
scope._selectionBox.camera = scope.camera;
scope._selectionBox.collection = [];
} else {
scope.camera = scope.scatterCam;
scope.control = scope.scatterController;
//Don't let the controller move around when its not the active camera
scope.scatterController.enabled = true;
scope.parallelController.enabled = false;
scope._selectionBox.camera = scope.camera;
scope._selectionBox.collection = [];
}
});
console.log('hello kalen');
this.addDecompositionsToScene();
this.updateCameraTarget();
this.control.update();
this.scatterController.addEventListener('change', function() {
scope.needsUpdate = true;
});
this.parallelController.addEventListener('change', function() {
scope.needsUpdate = true;
});
/**
* Object with "min" and "max" attributes each of which is an array with
* the ranges that covers all of the decomposition views.
* @type {Object}
*/
this.drawAxesWithColor('#FFFFFF');
this.drawAxesLabelsWithColor('#FFFFFF');
// initialize subscribers for event callbacks
/**
* Events allowed for callbacks. DO NOT EDIT.
* @type {String[]}
*/
this.EVENTS = ['click', 'dblclick', 'select'];
/** @private */
this._subscribers = {};
for (var i = 0; i < this.EVENTS.length; i++) {
this._subscribers[this.EVENTS[i]] = [];
}
// Add callback call when sample is clicked
// Double and single click together from: http://stackoverflow.com/a/7845282
var DELAY = 200, clicks = 0, timer = null;
$container.on('mousedown', function(event) {
clicks++;
if (clicks === 1) {
timer = setTimeout(function() {
scope._eventCallback('click', event);
clicks = 0;
}, DELAY);
}
else {
clearTimeout(timer);
scope._eventCallback('dblclick', event);
clicks = 0;
}
})
.on('dblclick', function(event) {
event.preventDefault(); //cancel system double-click event
});
// setup the selectionBox and selectionHelper objects and callbacks
this._addSelectionEvents($container);
this.control.update();
// register callback for populating info with clicked sample name
// set the timeout for fading out the info div
var infoDuration = 4000;
var infoTimeout = setTimeout(function() {
scope.$info.fadeOut();
}, infoDuration);
/**
*
* The functions showText and copyToClipboard are used in the 'click',
* 'dblclick', and 'select' events.
*
* When a sample is clicked we show a legend at the bottom left of the
* view. If this legend is clicked, we copy the sample name to the
* clipboard. When a sample is double-clicked we directly copy the sample
* name to the clipboard and add the legend at the bottom left of the view.
*
* When samples are selected we show a message on the bottom left of the
* view, and copy a comma-separated list of samples to the clipboard.
*
*/
function showText(n, i) {
clearTimeout(infoTimeout);
scope.$info.text(n);
scope.$info.show();
// reset the timeout for fading out the info div
infoTimeout = setTimeout(function() {
scope.$info.fadeOut();
scope.$info.text('');
}, infoDuration);
}
function copyToClipboard(text) {
var $temp = $('<input>');
// we need an input element to be able to copy to clipboard, taken from
// https://codepen.io/shaikmaqsood/pen/XmydxJ/
$('body').append($temp);
$temp.val(text).select();
document.execCommand('copy');
$temp.remove();
}
//Add info div as bottom of canvas
this.$info = $('<div>').attr('title', 'Click to copy to clipboard');
this.$info.css({'position': 'absolute',
'bottom': 0,
'height': 16,
'width': '50%',
'padding-left': 10,
'padding-right': 10,
'font-size': 12,
'z-index': 10000,
'background-color': 'rgb(238, 238, 238)',
'border': '1px solid black',
'font-family': 'Verdana,Arial,sans-serif'}).hide();
this.$info.click(function() {
var text = scope.$info.text();
// handle the case where multiple clicks are received
text = text.replace(/\(copied to clipboard\) /g, '');
copyToClipboard(text);
scope.$info.effect('highlight', {}, 500);
scope.$info.text('(copied to clipboard) ' + text);
});
$(this.renderer.domElement).parent().append(this.$info);
// UI callbacks specific to emperor, not to be confused with DOM events
this.on('click', showText);
this.on('dblclick', function(n, i) {
copyToClipboard(n);
showText('(copied to clipboard) ' + n, i);
});
this.on('select', function(names, view) {
if (names.length) {
showText(names.length + ' samples copied to your clipboard.');
copyToClipboard(names.join(','));
}
});
// if a decomposition uses a point cloud, or
// if a decomposition uses a parallel plot,
// update the default raycasting tolerance as
// it is otherwise too large and error-prone
var updateRaycasterLinePrecision = function(evt) {
if (scope.UIState.getProperty('view.viewType') === 'parallel-plot')
scope._raycaster.params.Line.threshold = 0.01;
else
scope._raycaster.params.Line.threshold = 1;
};
var updateRaycasterPointPrecision = function(evt) {
if (scope.UIState.getProperty('view.usesPointCloud'))
scope._raycaster.params.Points.threshold = 0.01;
else
scope._raycaster.params.Points.threshold = 1;
};
this.UIState.registerProperty('view.usesPointCloud',
updateRaycasterPointPrecision);
this.UIState.registerProperty('view.viewType',
updateRaycasterLinePrecision);
};
/**
* Builds a camera (for scatter or parallel plot)
*/
ScenePlotView3D.prototype.buildCamera = function(viewType) {
var camera;
if (viewType === 'scatter')
{
// Set up the camera
var max = _.max(this.decViews.scatter.decomp.dimensionRanges.max);
var frontFrust = _.min([max * 0.001, 1]);
var backFrust = _.max([max * 100, 100]);
// these are placeholders that are
// later updated in updateCameraAspectRatio
camera = new THREE.OrthographicCamera(-50, 50, 50, -50);
camera.position.set(0, 0, max * 5);
camera.zoom = 0.7;
}
else if (viewType === 'parallel-plot')
{
var w = this.decModels.dimensionRanges.max.length;
// Set up the camera
camera = new THREE.OrthographicCamera(0, w, 1, 0);
camera.position.set(0, 0, 1); //Must set positive Z because near > 0
camera.zoom = 0.7;
}
return camera;
};
/**
* Builds a camera controller (for scatter or parallel plot)
*/
ScenePlotView3D.prototype.buildCamController = function(viewType, cam, view) {
/**
* Object used to interact with the scene. By default it uses the mouse.
* @type {THREE.OrbitControls}
*/
var control = new THREE.OrbitControls(cam, view);
control.enableKeys = false;
control.rotateSpeed = 1.0;
control.zoomSpeed = 1.2;
control.panSpeed = 0.8;
control.enableZoom = true;
control.enablePan = true;
// don't free panning and rotation for paralle plots
control.screenSpacePanning = (viewType === 'scatter');
control.enableRotate = (viewType === 'scatter');
return control;
};
/**
*
* Adds all the decomposition views to the current scene.
*
*/
ScenePlotView3D.prototype.addDecompositionsToScene = function() {
var j, marker, scaling = this.getScalingConstant();
// Note that the internal logic of the THREE.Scene object prevents the
// objects from being re-added so we can simply iterate over all the
// decomposition views.
// Add all the meshes to the scene, iterate through all keys in
// decomposition view dictionary and put points in a separate group
for (var decViewName in this.decViews) {
var isArrowType = this.decViews[decViewName].decomp.isArrowType();
for (j = 0; j < this.decViews[decViewName].markers.length; j++) {
marker = this.decViews[decViewName].markers[j];
// only arrows include text as part of their markers
// arrows are not selectable
if (isArrowType) {
marker.label.scale.set(marker.label.scale.x * scaling,
marker.label.scale.y * scaling, 1);
this.scene.add(marker);
}
else {
this._selectable.add(marker);
}
}
for (j = 0; j < this.decViews[decViewName].ellipsoids.length; j++) {
this.scene.add(this.decViews[decViewName].ellipsoids[j]);
}
// if the left lines exist so will the right lines
if (this.decViews[decViewName].lines.left) {
this.scene.add(this.decViews[decViewName].lines.left);
this.scene.add(this.decViews[decViewName].lines.right);
}
}
this.needsUpdate = true;
};
/**
* Calculate a scaling constant for the text in the scene.
*
* It is important that this factor is calculated based on all the elements
* in a scene, and that it is the same for all the text elements in the
* scene. Otherwise, some text will be bigger than other.
*
* @return {Number} The scaling factor to use for labels.
*/
ScenePlotView3D.prototype.getScalingConstant = function() {
return (this.decModels.dimensionRanges.max[0] -
this.decModels.dimensionRanges.min[0]) * 0.001;
};
/**
*
* Helper method used to iterate over the ranges of the visible dimensions.
*
* This function that centralizes the pattern followed by drawAxesWithColor
* and drawAxesLabelsWithColor.
*
* @param {Function} action a function that can take up to three arguments
* "start", "end" and "index". And for each visible dimension the function
* will get the "start" and "end" of the range, and the current "index" of the
* visible dimension.
* @private
*
*/
ScenePlotView3D.prototype._dimensionsIterator = function(action) {
this.decModels._unionRanges();
if (this.UIState['view.viewType'] === 'scatter')
{
// shortcut to the index of the visible dimension and the range object
var x = this.visibleDimensions[0], y = this.visibleDimensions[1],
z = this.visibleDimensions[2], range = this.decModels.dimensionRanges,
is2D = (z === null || z === undefined);
// Adds a padding to all dimensions such that samples don't overlap
// with the axes lines. Determined based on the default sphere radius
var axesPadding = 1.07;
/*
* We special case Z when it is a 2D plot, whenever that's the case we set
* the range to be zero so no lines are shown on screen.
*/
// this is the "origin" of our ordination
var start = [range.min[x] * axesPadding,
range.min[y] * axesPadding,
is2D ? 0 : range.min[z] * axesPadding];
var ends = [
[range.max[x] * axesPadding,
range.min[y] * axesPadding,
is2D ? 0 : range.min[z] * axesPadding],
[range.min[x] * axesPadding,
range.max[y] * axesPadding,
is2D ? 0 : range.min[z] * axesPadding],
[range.min[x] * axesPadding,
range.min[y] * axesPadding,
is2D ? 0 : range.max[z] * axesPadding]
];
action(start, ends[0], x);
action(start, ends[1], y);
// when transitioning to 2D disable rotation to avoid awkward angles
if (is2D) {
this.control.enableRotate = false;
}
else {
action(start, ends[2], z);
this.control.enableRotate = true;
}
}
else {
//Parallel Plots show all axes
for (var i = 0; i < this.decViews['scatter'].decomp.dimensions; i++)
{
action([i, 0, 0], [i, 1, 0], i);
}
}
};
/**
*
* Draw the axes lines in the plot
*
* @param {String} color A CSS-compatible value that specifies the color
* of each of the axes lines, the length of these lines is determined by the
* global dimensionRanges property computed in decModels.
* If the color value is null the lines will be removed.
*
*/
ScenePlotView3D.prototype.drawAxesWithColor = function(color) {
var scope = this, axisLine;
// axes lines are removed if the color is null
this.removeAxes();
if (color === null) {
return;
}
this._dimensionsIterator(function(start, end, index) {
axisLine = makeLine(start, end, color, 3, false);
axisLine.name = scope._axisPrefix + index;
scope.scene.add(axisLine);
});
};
/**
*
* Draw the axes labels for each visible dimension.
*
* The text in the labels is determined using the percentage explained by
* each dimension and the abbreviated name of a single decomposition object.
* Note that we arbitrarily use the first one, as all decomposition objects
* presented in the same scene should have the same percentages explained by
* each axis.
*
* @param {String} color A CSS-compatible value that specifies the color
* of the labels, these labels will be positioned at the end of the axes
* line. If the color value is null the labels will be removed.
*
*/
ScenePlotView3D.prototype.drawAxesLabelsWithColor = function(color) {
var scope = this, axisLabel, decomp, firstKey, text, scaling;
scaling = this.getScalingConstant();
// the labels are only removed if the color is null
this.removeAxesLabels();
if (color === null) {
return;
}
// get the first decomposition object, it doesn't really matter which one
// we look at though, as all of them should have the same percentage
// explained on each axis
firstKey = _.keys(this.decViews)[0];
decomp = this.decViews[firstKey].decomp;
this._dimensionsIterator(function(start, end, index) {
text = decomp.axesLabels[index];
axisLabel = makeLabel(end, text, color);
if (scope.UIState['view.viewType'] === 'scatter') {
//Scatter has a 1 to 1 aspect ratio and labels in world size
axisLabel.scale.set(axisLabel.scale.x * scaling,
axisLabel.scale.y * scaling,
1);
}
else if (scope.UIState['view.viewType'] === 'parallel-plot') {
//Parallel plot aspect ratio depends on number of dimensions
//We have to correct label size to account for this.
//But we also have to fix label width so that it fits between
//axes, which are exactly 1 apart in world space
var cam = scope.camera;
var labelWPix = axisLabel.scale.x;
var labelHPix = axisLabel.scale.y;
var viewWPix = scope.width;
var viewHPix = scope.height;
//Assuming a camera zoom of 1:
var viewWUnits = cam.right - cam.left;
var viewHUnits = cam.top - cam.bottom;
//These are world sizes of label for a camera zoom of 1
var labelWUnits = labelWPix * viewWUnits / viewWPix;
var labelHUnits = labelHPix * viewHUnits / viewHPix;
//TODO FIXME HACK: Note that our options here are to scale each
//label to fit in its area, or to scale all labels by the same amount
//We choose to scale all labels by the same amount based on an
//empirical 'nice' label length of ~300
//We could replace this with a max of all label widths, but must note
//that label widths are always powers of 2 in the current version
//Resize to fit labels of width 300 between axes
var scalingFudge = 0.9 / (300 * viewWUnits / viewWPix);
axisLabel.scale.set(labelWUnits * scalingFudge,
labelHUnits * scalingFudge,
1);
}
axisLabel.name = scope._axisLabelPrefix + index;
scope.scene.add(axisLabel);
});
};
/**
*
* Helper method to remove objects with some prefix from the view's scene
*
* @param {String} prefix The prefix of object names to remove
*
*/
ScenePlotView3D.prototype._removeObjectsWithPrefix = function(prefix) {
var scope = this;
var recursiveRemove = function(rootObj) {
if (rootObj.name != null && rootObj.name.startsWith(prefix)) {
scope.scene.remove(rootObj);
}
else {
// We can't iterate the children array while removing from it,
// So we make a shallow copy.
var childCopy = Array.from(rootObj.children);
for (var child in childCopy) {
recursiveRemove(childCopy[child]);
}
}
};
recursiveRemove(this.scene);
};
/**
*
* Helper method to remove the axis lines from the scene
*
*/
ScenePlotView3D.prototype.removeAxes = function() {
this._removeObjectsWithPrefix(this._axisPrefix);
};
/**
*
* Helper method to remove the axis labels from the scene
*
*/
ScenePlotView3D.prototype.removeAxesLabels = function() {
this._removeObjectsWithPrefix(this._axisLabelPrefix);
};
/**
*
* Resizes and relocates the scene.
*
* @param {Float} xView New horizontal location.
* @param {Float} yView New vertical location.
* @param {Float} width New scene width.
* @param {Float} height New scene height.
*
*/
ScenePlotView3D.prototype.resize = function(xView, yView, width, height) {
this.xView = xView;
this.yView = yView;
this.width = width;
this.height = height;
this.updateCameraAspectRatio();
this.control.update();
//Since parallel plot labels have to correct for aspect ratio, we need
//to redraw when width/height of view is modified.
this.drawAxesLabelsWithColor(this.axesColor);
this.needsUpdate = true;
};
/**
*
* Resets the aspect ratio of the camera according to the current size of the
* plot space.
*
*/
ScenePlotView3D.prototype.updateCameraAspectRatio = function() {
if (this.UIState['view.viewType'] === 'scatter')
{
var x = this.visibleDimensions[0], y = this.visibleDimensions[1];
// orthographic cameras operate in space units not in pixel units i.e.
// the width and height of the view is based on the objects not the window
var owidth = this.decModels.dimensionRanges.max[x] -
this.decModels.dimensionRanges.min[x];
var oheight = this.decModels.dimensionRanges.max[y] -
this.decModels.dimensionRanges.min[y];
var aspect = this.width / this.height;
// ensure that the camera's aspect ratio is equal to the window's
owidth = oheight * aspect;
this.camera.left = -owidth / 2;
this.camera.right = owidth / 2;
this.camera.top = oheight / 2;
this.camera.bottom = -oheight / 2;
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
else if (this.UIState['view.viewType'] === 'parallel-plot')
{
var w = this.decModels.dimensionRanges.max.length;
this.camera.left = 0;
this.camera.right = w;
this.camera.top = 1;
this.camera.bottom = 0;
this.camera.updateProjectionMatrix();
}
};
/**
* Updates the target and dimensions of the camera and control
*
* The target of the scene depends on the coordinate space of the data, by
* default it is set to zero, but we need to make sure that the target is
* reasonable for the data.
*/
ScenePlotView3D.prototype.updateCameraTarget = function() {
if (this.UIState['view.viewType'] === 'scatter')
{
var x = this.visibleDimensions[0], y = this.visibleDimensions[1];
var owidth = this.decModels.dimensionRanges.max[x] -
this.decModels.dimensionRanges.min[x];
var oheight = this.decModels.dimensionRanges.max[y] -
this.decModels.dimensionRanges.min[y];
var xcenter = this.decModels.dimensionRanges.max[x] - (owidth / 2);
var ycenter = this.decModels.dimensionRanges.max[y] - (oheight / 2);
var max = _.max(this.decViews.scatter.decomp.dimensionRanges.max);
this.control.target.set(xcenter, ycenter, 0);
this.camera.position.set(xcenter, ycenter, max * 5);
this.camera.updateProjectionMatrix();
this.light.position.set(xcenter, ycenter, max * 5);
this.updateCameraAspectRatio();
this.control.saveState();
this.needsUpdate = true;
}
else if (this.UIState['view.viewType'] === 'parallel-plot') {
this.control.target.set(0, 0, 1); //Must set positive Z because near > 0
this.camera.position.set(0, 0, 1); //Must set positive Z because near > 0
this.camera.updateProjectionMatrix();
this.updateCameraAspectRatio();
this.control.saveState();
this.needsUpdate = true;
}
};
ScenePlotView3D.prototype.NEEDS_RENDER = 1;
ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH = 2;
/**
*
* Convenience method to check if this or any of the decViews under this need
* rendering
*
*/
ScenePlotView3D.prototype.checkUpdate = function() {
var updateDimensions = false, updateColors = false,
currentDimensions, backgroundColor, axesColor, scope = this;
//Check if the view type changed and swap the markers in/out of the scene
//tree.
var anyMarkersSwapped = false, isArrowType;
_.each(this.decViews, function(view) {
if (view.needsSwapMarkers) {
isArrowType = view.decomp.isArrowType();
anyMarkersSwapped = true;
// arrows are in the scene whereas points/markers are in a different
// group used for brush selection
var group = isArrowType ? scope.scene : scope._selectable;
var oldMarkers = view.getAndClearOldMarkers(), marker;
for (var i = 0; i < oldMarkers.length; i++) {
marker = oldMarkers[i];
group.remove(marker);
if (isArrowType) {
marker.dispose();
}
else {
marker.material.dispose();
marker.geometry.dispose();
}
}
// do not show arrows in a parallel plot
var newMarkers = view.markers;
if (isArrowType && scope.UIState['view.viewType'] === 'scatter' ||
view.decomp.isScatterType()) {
var scaling = scope.getScalingConstant();
for (i = 0; i < newMarkers.length; i++) {
marker = newMarkers[i];
// when we re-add arrows we need to re-scale the labels
if (isArrowType) {
marker.label.scale.set(marker.label.scale.x * scaling,
marker.label.scale.y * scaling, 1);
}
group.add(marker);
}
}
var lines = view.lines;
var ellipsoids = view.ellipsoids;
if (scope.UIState['view.viewType'] == 'parallel-plot') {
for (i = 0; i < lines.length; i++)
scope.scene.remove(lines[i]);
for (i = 0; i < ellipsoids.length; i++)
scope.scene.remove(ellipsoids[i]);
}
if (scope.UIState['view.viewType'] == 'scatter') {
for (i = 0; i < lines.length; i++)
scope.scene.add(lines[i]);
for (i = 0; i < ellipsoids.length; i++)
scope.scene.add(ellipsoids[i]);
}
}});
if (anyMarkersSwapped) {
this.updateCameraTarget();
this.control.update();
}
// check if any of the decomposition views have changed
var updateData = _.any(this.decViews, function(dv) {
// note that we may be overwriting these variables, but we have a
// guarantee that if one of them changes for one of decomposition views,
// all of them will have changed, so grabbing one should be sufficient to
// perform the comparisons below
currentDimensions = dv.visibleDimensions;
backgroundColor = dv.backgroundColor;
axesColor = dv.axesColor;
return dv.needsUpdate;
});
_.each(this.decViews, function(view) {
view.getTubes().forEach(function(tube) {
if (tube !== null)
scope.scene.add(tube);
});
});
// check if the visible dimensions have changed
if (!_.isEqual(currentDimensions, this.visibleDimensions)) {
// remove the current axes
this.removeAxes();
this.removeAxesLabels();
// get the new dimensions and re-display the data
this.visibleDimensions = _.clone(currentDimensions);
this.drawAxesWithColor(this.axesColor);
this.drawAxesLabelsWithColor(this.axesColor);
this.updateCameraTarget();
this.control.update();
updateDimensions = true;
}
// check if we should change the axes color
if (axesColor !== this.axesColor) {
this.drawAxesWithColor(axesColor);
this.drawAxesLabelsWithColor(axesColor);
this.axesColor = _.clone(axesColor);
updateColors = true;
}
// check if we should change the background color
if (backgroundColor !== this.backgroundColor) {
this.backgroundColor = _.clone(backgroundColor);
this.scene.background = new THREE.Color(this.backgroundColor);
updateColors = true;
}
if (updateData) {
this.drawAxesWithColor(this.axesColor);
this.drawAxesLabelsWithColor(this.axesColor);
}
var retVal = 0;
if (anyMarkersSwapped)
retVal |= ScenePlotView3D.prototype.NEEDS_CONTROLLER_REFRESH;
if (anyMarkersSwapped || this.needsUpdate || updateData ||
updateDimensions || updateColors || this.control.autoRotate)
retVal |= ScenePlotView3D.prototype.NEEDS_RENDER;
// if anything has changed, then trigger an update
return retVal;
};
/**
*
* Convenience method to re-render the contents of the scene.
*
*/
ScenePlotView3D.prototype.render = function() {
this.renderer.setViewport(this.xView, this.yView, this.width, this.height);
this.renderer.render(this.scene, this.camera);
var camera = this.camera;
// if autorotation is enabled, then update the controls
if (this.control.autoRotate) {
this.control.update();
}
// Only scatter plots that are not using a point cloud should be pointed
// towards the camera. For arrow types and point clouds doing this will
// results in odd visual effects
if (!this.UIState.getProperty('view.usesPointCloud') &&
this.decViews.scatter.decomp.isScatterType()) {
_.each(this.decViews.scatter.markers, function(element) {
element.quaternion.copy(camera.quaternion);
});
}
this.needsUpdate = false;
$.each(this.decViews, function(key, val) {
val.needsUpdate = false;
});
};
/**
* Helper method to highlight and return selected objects.
*
* This is mostly necessary because depending on the rendering type we will
* have a slightly different way to set and return the highlighting
* attributes. For large plots we return the points geometry together with
* a userData.selected attribute with the selected indices.
*
* Note that we created a group of selectable objects in the constructor so
* we don't have to check for geometry types, etc.
*
* @param {Array} collection An array of objects to highlight
* @param {Integer} color A hexadecimal-encoded color. For shaders we only
* use the first bit to decide if the marker is rendered in white or rendered
* with the original color.
*
* @return {Array} selected objects (after checking for visibility and
* opacity).
*
* @private
*/
ScenePlotView3D.prototype._highlightSelected = function(collection, color) {
var i = 0, j = 0, selected = [];
if (this.UIState.getProperty('view.usesPointCloud') ||
this.UIState.getProperty('view.viewType') === 'parallel-plot') {
for (i = 0; i < collection.length; i++) {
// for shaders the emissive attribute is an int
var indices, emissiveColor = (color > 0) * 1;
// if there's no selection then update all the points
if (collection[i].userData.selected === undefined) {
indices = _.range(collection[i].geometry.attributes.emissive.count);
}
else {
indices = collection[i].userData.selected;
}
for (j = 0; j < indices.length; j++) {
if (collection[i].geometry.attributes.visible.getX(indices[j]) &&
collection[i].geometry.attributes.opacity.getX(indices[j])) {
collection[i].geometry.attributes.emissive.setX(indices[j],
emissiveColor);
}
}
collection[i].geometry.attributes.emissive.needsUpdate = true;
selected.push(collection[i]);
}
}
else {
for (i = 0; i < collection.length; i++) {
var material = collection[i].material;
if (material.visible && material.opacity && material.emissive) {
collection[i].material.emissive.set(color);
selected.push(collection[i]);
}
}
}
return selected;
};
/**
*
* Adds the mouse selection events to the current view
*
* @param {node} $container The container to add the events to.
* @private
*/
ScenePlotView3D.prototype._addSelectionEvents = function($container) {
var scope = this;
// There're three stages to the mouse selection:
// mousedown -> mousemove -> mouseup
//
// The mousdown event is ignored unless the user is holding Shift. Once
// selection has started the rotation controls are disabled. The mousemove
// event continues until the user releases the mouse. Once this happens
// rotation is re-enabled and the selection box disappears. Selected
// markers are highlighted by changing the light they emit.
//
$container.on('mousedown', function(event) {
// ignore the selection event if shift is not being held or if parallel
// plots are being visualized at the moment
if (!event.shiftKey) {
return;
}
scope.control.enabled = false;
scope.scatterController.enabled = false;
scope.parallelController.enabled = false;
scope._selectionHelper.enabled = true;
scope._selectionHelper.onSelectStart(event);
// clear up any color setting
scope._highlightSelected(scope._selectionBox.collection, 0x000000);
var element = scope.renderer.domElement;
var offset = $(element).offset(), i = 0;
scope._selectionBox.startPoint.set(
((event.clientX - offset.left) / element.width) * 2 - 1,
-((event.clientY - offset.top) / element.height) * 2 + 1,
0.5);
})
.on('mousemove', function(event) {
// ignore if the user is not holding the shift key or the orbit control
// is enabled and he selection disabled
if (!event.shiftKey ||
(scope.control.enabled && !scope._selectionHelper.enabled)) {
return;
}
var element = scope.renderer.domElement, selected;
var offset = $(element).offset(), i = 0;
scope._selectionBox.endPoint.set(
((event.clientX - offset.left) / element.width) * 2 - 1,
- ((event.clientY - offset.top) / element.height) * 2 + 1,
0.5);
// reset everything before updating the selected color
scope._highlightSelected(scope._selectionBox.collection, 0x000000);
scope._highlightSelected(scope._selectionBox.select(), 0x8c8c8f);
scope.needsUpdate = true;
})
.on('mouseup', function(event) {
// if the user is not already selecting data then ignore
if (!scope._selectionHelper.enabled || scope.control.enabled) {
return;
}
// otherwise if shift is being held then keep selecting, otherwise ignore
if (event.shiftKey) {
var element = scope.renderer.domElement;
var offset = $(element).offset(), indices = [], names = [];
scope._selectionBox.endPoint.set(
((event.clientX - offset.left) / element.width) * 2 - 1,
- ((event.clientY - offset.top) / element.height) * 2 + 1,
0.5);
selected = scope._highlightSelected(scope._selectionBox.select(),
0x8c8c8f);
// get the list of sample names from the views
for (var i = 0; i < selected.length; i++) {
if (selected[i].isPoints) {
// this is a list of indices of the selected samples
indices = selected[i].userData.selected;
for (var j = 0; j < indices.length; j++) {
names.push(scope.decViews.scatter.decomp.ids[indices[j]]);
}
}
else if (selected[i].isLineSegments) {
var index, viewType, view;
view = scope.decViews.scatter;
viewType = scope.UIState['view.viewType'];
// this is a list of indices of the selected samples
indices = selected[i].userData.selected;
for (var k = 0; k < indices.length; k++) {
index = view.getModelPointIndex(indices[k], viewType);
names.push(view.decomp.ids[index]);
}
// every segment is labeled the same for each sample
names = _.unique(names);
}
else {
names.push(selected[i].name);
}
}
scope._selectCallback(names, scope.decViews.scatter);
}
scope.control.enabled = true;
scope.scatterController.enabled = true;
scope.parallelController.enabled = true;
scope._selectionHelper.enabled = false;
scope.needsUpdate = true;
});
};
/**
* Handle selection events.
* @private
*/
ScenePlotView3D.prototype._selectCallback = function(names, view) {
var eventType = 'select';
for (var i = 0; i < this._subscribers[eventType].length; i++) {
// keep going if one of the callbacks fails
try {
this._subscribers[eventType][i](names, view);
} catch (e) {
console.error(e);
}
this.needsUpdate = true;
}
};
/**
*
* Helper method that runs functions subscribed to the container's callbacks.
* @param {String} eventType Event type being called
* @param {event} event The event from jQuery, with x and y click coords
* @private
*
*/
ScenePlotView3D.prototype._eventCallback = function(eventType, event) {
event.preventDefault();
// don't do anything if no subscribers
if (this._subscribers[eventType].length === 0) {
return;
}
var element = this.renderer.domElement, scope = this;
var offset = $(element).offset();
this._mouse.x = ((event.clientX - offset.left) / element.width) * 2 - 1;
this._mouse.y = -((event.clientY - offset.top) / element.height) * 2 + 1;
this._raycaster.setFromCamera(this._mouse, this.camera);
// get a flattened array of markers
var objects = _.map(this.decViews, function(decomp) {
return decomp.markers;
});
objects = _.reduce(objects, function(memo, value) {
return memo.concat(value);
}, []);
var intersects = this._raycaster.intersectObjects(objects);
// Get first intersected item and call callback with it.
if (intersects && intersects.length > 0) {
var firstObj = intersects[0].object, intersect;
/*
* When the intersect object is a Points object, the raycasting method
* won't intersect individual mesh objects. Instead it intersects a point
* and we get the index of the point. This index can then be used to
* trace the original Plottable object.
*/
if (firstObj.isPoints || firstObj.isLineSegments) {
// don't search over invisible things
intersects = _.filter(intersects, function(marker) {
return firstObj.geometry.attributes.visible.getX(marker.index) &&
firstObj.geometry.attributes.opacity.getX(marker.index);
});
// if there's no hits then finish the execution
if (intersects.length === 0) {
return;
}
var meshIndex = intersects[0].index;
var modelIndex = this.decViews.scatter.getModelPointIndex(meshIndex,
this.UIState['view.viewType']);
intersect = this.decViews.scatter.decomp.plottable[modelIndex];
}
else {
intersects = _.filter(intersects, function(marker) {
return marker.object.visible && marker.object.material.opacity;
});
// if there's no hits then finish the execution
if (intersects.length === 0) {
return;
}
intersect = intersects[0].object;
}
for (var i = 0; i < this._subscribers[eventType].length; i++) {
// keep going if one of the callbacks fails
try {
this._subscribers[eventType][i](intersect.name, intersect);
} catch (e) {
console.error(e);
}
this.needsUpdate = true;
}
}
};
/**
*
* Interface to subscribe to event types in the canvas, see the EVENTS
* property.
*
* @param {String} eventType The type of event to subscribe to.
* @param {Function} handler Function to call when `eventType` is triggered,
* receives two parameters, a string with the name of the object, and the
* object itself i.e. f(objectName, object).
*
* @throws {Error} If the given eventType is unknown.
*
*/
ScenePlotView3D.prototype.on = function(eventType, handler) {
if (this.EVENTS.indexOf(eventType) === -1) {
throw new Error('Unknown event ' + eventType + '. Known events are: ' +
this.EVENTS.join(', '));
}
this._subscribers[eventType].push(handler);
};
/**
*
* Interface to unsubscribe a function from an event type, see the EVENTS
* property.
*
* @param {String} eventType The type of event to unsubscribe from.
* @param {Function} handler Function to remove from the subscribers list.
*
* @throws {Error} If the given eventType is unknown.
*
*/
ScenePlotView3D.prototype.off = function(eventType, handler) {
if (this.EVENTS.indexOf(eventType) === -1) {
throw new Error('Unknown event ' + eventType + '. Known events are ' +
this.EVENTS.join(', '));
}
var pos = this._subscribers[eventType].indexOf(handler);
if (pos !== -1) {
this._subscribers[eventType].splice(pos, 1);
}
};
/**
*
* Recenter the position of the camera to the initial default.
*
*/
ScenePlotView3D.prototype.recenterCamera = function() {
this.control.reset();
this.control.update();
this.needsUpdate = true;
};
return ScenePlotView3D;
});