diff --git a/empress/core.py b/empress/core.py index 401e3ade..a86c1bd2 100644 --- a/empress/core.py +++ b/empress/core.py @@ -144,6 +144,7 @@ def __init__(self, tree, table=None, sample_metadata=None, self.features = None self.ordination = ordination + self.is_empire_plot = (self.ordination is not None) self.base_url = resource_path if self.base_url is None: @@ -157,7 +158,7 @@ def __init__(self, tree, table=None, sample_metadata=None, shear_to_feature_metadata, ) - if self.ordination is not None: + if self.is_empire_plot: # biplot arrows can optionally have metadata, think for example # a study where the arrows represent pH, Alkalinity, etc. @@ -365,6 +366,8 @@ def to_dict(self): 'names': names, # Should we show sample metadata coloring / animation panels? 'is_community_plot': self.is_community_plot, + # Are we working with an EMPire plot? + 'is_empire_plot': self.is_empire_plot, # feature table 's_ids': s_ids, 'f_ids': f_ids, diff --git a/empress/support_files/css/empress.css b/empress/support_files/css/empress.css index 2056ba47..49a3d02b 100644 --- a/empress/support_files/css/empress.css +++ b/empress/support_files/css/empress.css @@ -664,11 +664,17 @@ p.side-header button:hover, /* Somewhere else in the CSS adjusts line height and justify-content, * so we reset these here. + * + * Also, the reason for display: block is because the .control p CSS + * elsewhere causes weird problems when there are other tags (e.g. + * s, like in the shearing UI) within

tags of class + * side-panel-notes due to the flex display. */ .side-panel-notes { font-size: small; line-height: normal !important; justify-content: start !important; + display: block !important; } .indented { diff --git a/empress/support_files/js/animation-panel-handler.js b/empress/support_files/js/animation-panel-handler.js index e7957d12..3c2f4c55 100644 --- a/empress/support_files/js/animation-panel-handler.js +++ b/empress/support_files/js/animation-panel-handler.js @@ -6,11 +6,13 @@ define(["Colorer", "util"], function (Colorer, util) { * Creates tab for the animation panel and handles their events events. * * @param{Object} animator The object that creates the animations + * @param{EnableDisableAnimationTab} tab The Animation tab * * @return {AnimationPanel} * construct AnimationPanel */ - function AnimationPanel(animator) { + function AnimationPanel(animator, tab) { + this.tab = tab; // used in event triggers this.animator = animator; @@ -45,6 +47,23 @@ define(["Colorer", "util"], function (Colorer, util) { this._onAnimationStopped = null; } + /* + * Enables the Animation tab. This will result in the Animation tab + * containing its original content. + */ + AnimationPanel.prototype.enableTab = function () { + this.tab.enableTab(); + }; + + /* + * Disables the Animation tab. This will result in the Animation tab + * containing a message describing why the tab has been disabled and how to + * re-enable it. + */ + AnimationPanel.prototype.disableTab = function () { + this.tab.disableTab(); + }; + /** * Makes the play button visible. This is the menu shown before user has * started the animation. diff --git a/empress/support_files/js/animator.js b/empress/support_files/js/animator.js index 7f64f0c8..9e2bb583 100644 --- a/empress/support_files/js/animator.js +++ b/empress/support_files/js/animator.js @@ -7,11 +7,15 @@ define(["Colorer", "util"], function (Colorer, util) { * * @param{Empress} empress The core class. Entry point for all metadata and * tree operations. + * @param{Array} sidePanelTabs An array of EnableDisableSidePanelTab + * for the side-panel tabs. These tabs will be + * disabled while the animator is active and + * enabled otherwise. * * @returns{Animator} * @constructs Animator */ - function Animator(empress) { + function Animator(empress, sidePanelTabs) { /** * @type {Empress} * The Empress state machine @@ -109,7 +113,35 @@ define(["Colorer", "util"], function (Colorer, util) { * Extra width for branches. */ this.lWidth = 0; + + /** + * @type {Array} + * Stores the side-panel tabs. These tabs will be disabled + * while the animator is active and enabled otherwise. + * Each element of this array is an EnableDisableSidePanelTab object + */ + this.sidePanelTabs = sidePanelTabs; } + /* + * Enables the side panel tabs. This will result in the side panel tabs + * containing their original content. + */ + Animator.prototype.disableSidePanelTabs = function () { + _.each(this.sidePanelTabs, function (tab) { + tab.disableTab(); + }); + }; + + /* + * Disables the side panel tabs. This will result in the side panel tabs + * containing a message describing why the tabs have been disabled and how + * to re-enable them. + */ + Animator.prototype.enableSidePanelTabs = function () { + _.each(this.sidePanelTabs, function (tab) { + tab.enableTab(); + }); + }; /** * Sets the parameters for the animation state machine. @@ -310,6 +342,7 @@ define(["Colorer", "util"], function (Colorer, util) { * start the animation loop. */ Animator.prototype.startAnimation = function () { + this.disableSidePanelTabs(); this.initAnimation(); // start animation loop @@ -338,6 +371,7 @@ define(["Colorer", "util"], function (Colorer, util) { * Stops the animation and clears state machine parameters */ Animator.prototype.stopAnimation = function () { + this.enableSidePanelTabs(); this.__resetParams(); this.empress.clearLegend(); this.empress.resetTree(); diff --git a/empress/support_files/js/biom-table.js b/empress/support_files/js/biom-table.js index 800529d8..e36739ce 100644 --- a/empress/support_files/js/biom-table.js +++ b/empress/support_files/js/biom-table.js @@ -86,6 +86,14 @@ define(["underscore", "util"], function (_, util) { this._tbl = tbl; this._smCols = smCols; this._sm = sm; + + /** + * A set of feature IDs to ignore. This will be updated whenever + * the tree is sheared, and will contain the IDs of the features in + * the BIOM table (tips) that were removed. + * @ type {Set} + */ + this.ignorefIdx = new Set(); } /** @@ -278,7 +286,9 @@ define(["underscore", "util"], function (_, util) { var cVal; var addSampleFeatures = function (sIdx, cVal) { _.each(scope._tbl[sIdx], function (fIdx) { - valueToFeatureIdxs[cVal].add(fIdx); + if (!scope.ignorefIdx.has(fIdx)) { + valueToFeatureIdxs[cVal].add(fIdx); + } }); }; // For each sample... @@ -582,6 +592,10 @@ define(["underscore", "util"], function (_, util) { var fID2Freqs = {}; var totalSampleCount; _.each(this._fIDs, function (fID, fIdx) { + // we dont want to consider features that have been marked as ignore + if (scope.ignorefIdx.has(fIdx)) { + return; + } totalSampleCount = fIdx2SampleCt[fIdx]; fID2Freqs[fID] = {}; _.each(fIdx2Counts[fIdx], function (count, smValIdx) { @@ -591,8 +605,27 @@ define(["underscore", "util"], function (_, util) { } }); }); + return fID2Freqs; }; + /** + * Set which features to ignore. Features in this set will not be + * considered in functions such as getObsBy() or getFrequencyMap() + * + * @param {Set} nodes A set of feature ids to ignore + */ + BIOMTable.prototype.setIgnoreNodes = function (nodes) { + var scope = this; + + // convert feature ids to feature indices + // [...nodes] converts nodes from Set to Array: see + // https://stackoverflow.com/a/63818423 + var nodeIdx = _.map([...nodes], (fId) => { + return scope._getFeatureIndexFromID(fId); + }); + this.ignorefIdx = new Set(nodeIdx); + }; + return BIOMTable; }); diff --git a/empress/support_files/js/bp-tree.js b/empress/support_files/js/bp-tree.js index a9077da9..a5de5135 100644 --- a/empress/support_files/js/bp-tree.js +++ b/empress/support_files/js/bp-tree.js @@ -131,7 +131,8 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * performance boost */ var eCache = []; - for (var i = 0; i < this.b_.length; i++) { + var i; + for (i = 0; i < this.b_.length; i++) { eCache.push(2 * this.r1Cache_[i] - i - 1); } this.eCache_ = new Uint32Array(eCache); @@ -190,6 +191,33 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * that have the same name. */ this._nameToNodes = {}; + + /** + * @type {Array} + * @private + * Cache Parent nodes. This will help speed up a lot of methods that + * make calls to parent. + */ + this._pCache = []; + var pStack = []; + for (i = 0; i < this.b_.length - 1; i++) { + if (this.b_[i] === 1) { + if (pStack.length === 0) { + pStack.push(i); + this._pCache[i] = 0; + } else if (this.b_[i + 1] === 1) { + this._pCache[i] = pStack[pStack.length - 1]; + pStack.push(i); + } else if (this.b_[i + 1] === 0) { + this._pCache[i] = pStack[pStack.length - 1]; + } + } else if (this.b_[i] === 0) { + this._pCache[i] = pStack[pStack.length - 1]; + if (this.b_[i + 1] === 0) { + pStack.pop(); + } + } + } } /** @@ -204,7 +232,11 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * testing). */ BPTree.prototype.getLengthStats = function () { - if (this.lengths_ !== null) { + // first check if tree only contains 1 node (length === 2 since the + // lengths array starts at index 1.) + if (this.lengths_ !== null && this.lengths_.length === 2) { + return { min: "N/A", max: "N/A", avg: "N/A" }; + } else if (this.lengths_ !== null) { var min = Number.POSITIVE_INFINITY, max = Number.NEGATIVE_INFINITY, sum = 0, @@ -452,7 +484,7 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { return -1; } - return this.enclose(i); + return this._pCache[i]; }; /** @@ -702,7 +734,9 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { // find first and last preorder positions of the subtree spanned // by the current internal node var n = this.postorderselect(nodeKey); - if (this.isleaf(n)) { + + // check for root to account for cases when all tips have been sheared + if (this.isleaf(n) && nodeKey !== this.postorder(this.root())) { throw "Error: " + nodeKey + " is a tip!"; } var start = this.preorder(this.fchild(n)); @@ -972,53 +1006,69 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { }; /** - * Returns a new BPTree object that contains just the tips (and ancestors) - * of the nodes in keepTips. + * Returns a new BPTree object that does not contain the tips in removeTips. * * This method was ported from iow. * https://github.com/wasade/improved-octo-waddle/blob/0e9e75b77238acda6752f59d940620f89607ba6b/bp/_bp.pyx#L732 * - * @param {Set} keepTips The set of tip names to keep. + * @param {Set} removeTips The set of tip names to remove. * * @return {Object} An object containing the new tree ("tree") and two maps that * convert the original postorder positions to the sheared * tree postorder positions ("newToOld") and vice-versa ("oldToNew"). */ - BPTree.prototype.shear = function (keepTips) { + BPTree.prototype.shear = function (removeTips) { // closure var scope = this; - // create new names and lengths array - var names = [null]; - var lengths = [null]; - // create new bit array - var mask = []; + var mask = _.clone(this.b_); - // function to that will set open/close bits for a node - var set_bits = (node) => { - mask[node] = 1; - mask[scope.close(node)] = 0; - }; + var i, node; - // set root open/close bits - set_bits(this.root()); + // remove tips + for (i of removeTips) { + node = this.postorderselect(i); + mask[node] = undefined; + mask[node + 1] = undefined; + } - // iterate over bp tree in post order and add all tips that are in - // keepTips plus their ancestors - var i; - for (i = 1; i <= this.size; i++) { - var node = this.postorderselect(i); - var name = this.name(node); - if (this.isleaf(node) && keepTips.has(name)) { - // set open/close bits for tip - set_bits(node); - - // set open/close bits for tips ancestors - var parent = this.parent(node); - while (parent !== this.root() || mask[parent] !== 1) { - set_bits(parent); - parent = this.parent(parent); + // remove internal + var nodeStack = []; + for (i = mask.length - 1; i > 0; i--) { + if (mask[i] === 0) { + // close parentheses + if (mask[i - 1] === 0) { + // close parentheses represents internal node + nodeStack.push([i, null]); + } else if (mask[i - 1] === 1) { + // close parentheses represents non-removed tip + // thus we mark it as false so it is not removed + nodeStack.push([i, false]); + } else if (mask[i - 1] === undefined) { + // close parentheses represents an internal node + // with at least one removed tip so we temporarly mark it + // as true to remove + nodeStack.push([i, true]); + } + } else if (mask[i] === 1) { + // open parentheses + node = nodeStack.pop(); + if (node[1] === true) { + // remove internal node + mask[i] = undefined; + mask[node[0]] = undefined; + } else if (node[1] === false) { + // need explicitly check false since it can be null + // if node[1] is false that means it contains a tip and thus + // we need to mark its parent as false so it is not removed + nodeStack[nodeStack.length - 1][1] = false; + } + + if (nodeStack[nodeStack.length - 1][1] === null) { + // if null then we set its remove status to whatever node + // was + nodeStack[nodeStack.length - 1][1] = node[1]; } } } @@ -1027,6 +1077,9 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { var shearedToFull = new Map(); var fullToSheared = new Map(); var postorderPos = 1; + // create new names and lengths array + var names = [null]; + var lengths = [null]; for (i = 0; i < mask.length; i++) { if (mask[i] !== undefined) { newBitArray.push(mask[i]); @@ -1034,7 +1087,6 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { // get name and length of node // Note: names and lengths of nodes are stored in postorder - if (mask[i] === 0) { names.push(this.name(i)); lengths.push(this.length(i)); @@ -1043,6 +1095,7 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { postorderPos += 1; } } + return { shearedToFull: shearedToFull, fullToSheared: fullToSheared, diff --git a/empress/support_files/js/canvas-events.js b/empress/support_files/js/canvas-events.js index cdca090d..ecf50ed7 100644 --- a/empress/support_files/js/canvas-events.js +++ b/empress/support_files/js/canvas-events.js @@ -164,7 +164,9 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( // Go through all the nodes in the tree and find the node // closest to the (x, y) point that was clicked - for (var node = 1; node <= empress._tree.size; node++) { + for (var node of empress._tree.postorderTraversal( + (includeRoot = true) + )) { if (!empress.getNodeInfo(node, "visible")) continue; var nodeX = empress.getX(node); var nodeY = empress.getY(node); diff --git a/empress/support_files/js/drawer.js b/empress/support_files/js/drawer.js index 3afd6664..8b304f99 100644 --- a/empress/support_files/js/drawer.js +++ b/empress/support_files/js/drawer.js @@ -526,6 +526,8 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( * @param{Number} zoomAmount The amout to zoom in or out. */ Drawer.prototype.zoom = function (x, y, zoomIn, zoomAmount = this.scaleBy) { + // zoomAmount can be zero if all tips in the tree were sheared. + if (zoomAmount === 0) zoomAmount = this.scaleBy; var zoomAt = gl.vec4.fromValues(x, y, 0, 1); // move tree var transVec = gl.vec3.create(); diff --git a/empress/support_files/js/emperor-callbacks.js b/empress/support_files/js/emperor-callbacks.js index 82754bad..c46d7fa6 100644 --- a/empress/support_files/js/emperor-callbacks.js +++ b/empress/support_files/js/emperor-callbacks.js @@ -1,9 +1,30 @@ +/** + * helper function that will color the empress tree according to + * colorSampleGroups. + * + * @param {object} colorSampleGroups An object whose property names are html + * hex color stings and associated values are + * arrays of sample ids. + * example: + * { + * #008000: ['s1', 's2', 's3'] + * #000080: ['s1', 's3', 's4'] + * } + */ +var emperorCallbackColorEmpress = function (colorSampleGroups) { + // if there's any coloring setup remove it, and re-enable the update button + sPanel.sUpdateBtn.classList.remove("hidden"); + sPanel.fUpdateBtn.classList.remove("hidden"); + empress.clearLegend(); + empress.resetTree(); + empress.colorSampleGroups(colorSampleGroups); +}; + /* * This file is intended to be used only with Emperor. For more information * about these and other events see this document: * https://github.com/biocore/emperor/blob/master/doc/source/js_integration.rst */ - empress.setOnNodeMenuVisibleCallback(function (samples) { // reset scale settings for all samples ec.decViews.scatter.setScale(1); @@ -64,15 +85,12 @@ plotView.on("click", function (name, object) { }); plotView.on("select", function (samples, view) { + // remove any ongoing observers + shearer.unregisterObserver("emperor-select"); + // cancel any ongoing timers clearTimeout(empress.timer); - // if there's any coloring setup remove it, and re-enable the update button - sPanel.sUpdateBtn.classList.remove("hidden"); - sPanel.fUpdateBtn.classList.remove("hidden"); - empress.clearLegend(); - empress.resetTree(); - // fetch a mapping of colors to plottable objects var groups = view.groupByColor(samples); var namesOnly = {}; @@ -83,9 +101,19 @@ plotView.on("select", function (samples, view) { return item.name; }); } - empress.colorSampleGroups(namesOnly); + + // color the tree using the samples + var colorEmpress = () => { + emperorCallbackColorEmpress(namesOnly); + }; + colorEmpress(); // 4 seconds before resetting + var shearObs = { + shearerObserverName: "emperor-select", + shearUpdate: colorEmpress, + }; + shearer.registerObserver(shearObs); empress.timer = setTimeout(function () { empress.resetTree(); empress.drawTree(); @@ -95,6 +123,7 @@ plotView.on("select", function (samples, view) { } plotView.needsUpdate = true; + shearer.unregisterObserver("emperor-select"); }, 4000); }); @@ -127,6 +156,7 @@ ec.controllers.animations.addEventListener("animation-started", function ( // animation starts animationPanel.startOptions(); animationPanel.setEnabled(false); + animationPanel.disableTab(); animator.setAnimationParameters( payload.message.trajectory, @@ -136,6 +166,7 @@ ec.controllers.animations.addEventListener("animation-started", function ( animator.lWidth ); animator.initAnimation(); + animator.disableSidePanelTabs(); }); ec.controllers.animations.addEventListener( @@ -150,6 +181,7 @@ ec.controllers.animations.addEventListener("animation-cancelled", function ( ) { // if the animation is cancelled enable the animation controls animationPanel.setEnabled(true); + animationPanel.enableTab(); animator.stopAnimation(); }); @@ -169,6 +201,9 @@ ec.controllers.animations.addEventListener("animation-ended", function ( ec.controllers.color.addEventListener("value-double-clicked", function ( payload ) { + // remove any ongoing observers + shearer.unregisterObserver("emperor-value-double-clicked"); + // when dealing with a biplot ignore arrow-emitted events if (payload.target.decompositionName() !== "scatter") { return; @@ -182,24 +217,28 @@ ec.controllers.color.addEventListener("value-double-clicked", function ( ec.decViews.scatter.setEmissive(0x000000); plotView.needsUpdate = true; - // if there's any coloring setup remove it, and re-enable the update button - sPanel.sUpdateBtn.classList.remove("hidden"); - sPanel.fUpdateBtn.classList.remove("hidden"); - empress.clearLegend(); - empress.resetTree(); - var names = _.map(payload.message.group, function (item) { return item.name; }); var container = {}; container[payload.message.attribute] = names; - empress.colorSampleGroups(container); + + var colorEmpress = () => { + emperorCallbackColorEmpress(container); + }; + colorEmpress(); // 4 seconds before resetting + var shearObs = { + shearerObserverName: "emperor-value-double-clicked", + shearUpdate: colorEmpress, + }; + shearer.registerObserver(shearObs); empress.timer = setTimeout(function () { empress.resetTree(); empress.drawTree(); plotView.needsUpdate = true; + shearer.unregisterObserver("emperor-value-double-clicked"); }, 4000); }); diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index 6ecbd87b..fc803003 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -407,23 +407,41 @@ define([ branchMethod, this._tree.getTree() ); + var dataForOnlyRoot = function (coordKeys) { + var rootCoordData = {}; + _.each(coordKeys, function (key) { + rootCoordData[key] = [null, 0]; + }); + return rootCoordData; + }; // Rectangular if (this._currentLayout === "Rectangular") { - data = LayoutsUtil.rectangularLayout( - this._tree.getTree(), - 4020, - 4020, - // since lengths for "ignoreLengths" are set by `lengthGetter`, - // we don't need (and should likely deprecate) the ignoreLengths - // option for the Layout functions since the layout function only - // needs to know lengths in order to layout a tree, it doesn't - // really need encapsulate all of the logic for determining - // what lengths it should lay out. - this.leafSorting, - undefined, - lengthGetter, - checkLengthsChange - ); + // tree is just root + if (this._tree.currentSize == 1) { + data = dataForOnlyRoot([ + "xCoord", + "yCoord", + "highestChildYr", + "lowestChildYr", + "yScalingFactor", + ]); + } else { + data = LayoutsUtil.rectangularLayout( + this._tree.getTree(), + 4020, + 4020, + // since lengths for "ignoreLengths" are set by `lengthGetter`, + // we don't need (and should likely deprecate) the ignoreLengths + // option for the Layout functions since the layout function only + // needs to know lengths in order to layout a tree, it doesn't + // really need encapsulate all of the logic for determining + // what lengths it should lay out. + this.leafSorting, + undefined, + lengthGetter, + checkLengthsChange + ); + } this._yrscf = data.yScalingFactor; for (i of this._tree.postorderTraversal((includeRoot = true))) { // remove old layout information @@ -439,15 +457,29 @@ define([ j += 1; } } else if (this._currentLayout === "Circular") { - data = LayoutsUtil.circularLayout( - this._tree.getTree(), - 4020, - 4020, - this.leafSorting, - undefined, - lengthGetter, - checkLengthsChange - ); + if (this._tree.currentSize == 1) { + data = dataForOnlyRoot([ + "x0", + "y0", + "x1", + "y1", + "angle", + "arcx0", + "arcy0", + "arcStartAngle", + "arcEndAngle", + ]); + } else { + data = LayoutsUtil.circularLayout( + this._tree.getTree(), + 4020, + 4020, + this.leafSorting, + undefined, + lengthGetter, + checkLengthsChange + ); + } for (i of this._tree.postorderTraversal((includeRoot = true))) { // remove old layout information this._treeData[i].length = this._numOfNonLayoutParams; @@ -467,14 +499,18 @@ define([ j += 1; } } else { - data = LayoutsUtil.unrootedLayout( - this._tree.getTree(), - 4020, - 4020, - undefined, - lengthGetter, - checkLengthsChange - ); + if (this._tree.currentSize == 1) { + data = dataForOnlyRoot(["xCoord", "yCoord"]); + } else { + data = LayoutsUtil.unrootedLayout( + this._tree.getTree(), + 4020, + 4020, + undefined, + lengthGetter, + checkLengthsChange + ); + } for (i of this._tree.postorderTraversal((includeRoot = true))) { // remove old layout information this._treeData[i].length = this._numOfNonLayoutParams; @@ -495,11 +531,7 @@ define([ Empress.prototype.initialize = function () { this._drawer.initialize(); this._events.setMouseEvents(); - var nodeNames = this._tree.getAllNames(); - // Don't include nodes with the name null (i.e. nodes without a - // specified name in the Newick file) in the auto-complete. - nodeNames = nodeNames.filter((n) => n !== null); - this._events.autocomplete(nodeNames); + this.setAutoCompleteNames(); this.getLayoutInfo(); this.centerLayoutAvgPoint(); @@ -918,7 +950,6 @@ define([ } else { throw new Error("getNodeCoords() drawNodeCircles is out of range"); } - for (var node of this._tree.postorderTraversal((includeRoot = true))) { if (!comp(node)) { continue; @@ -2233,6 +2264,11 @@ define([ // colors for the legend var keyInfo = colorer.getMapHex(); + // if the tree has been sheared then categories in obs maybe empty. + // getObsBy() does not filter out those categories so that the same + // color can be assigned to each value in obs. + util.removeEmptyArrayKeys(keyInfo, obs); + // shared by the following for loops var i, j, category; @@ -2252,6 +2288,9 @@ define([ // If there aren't *any* sample metadata values unique to any tips, // then return null so that the caller can warn the user. if (Object.keys(obs).length === 0) { + // still want to update legend to match behavior of + // colorByFeatureMetadata + this.updateLegendCategorical(cat, keyInfo); return null; } @@ -2372,6 +2411,9 @@ define([ * -If method is not "tip" or "all" */ Empress.prototype.getUniqueFeatureMetadataInfo = function (cat, method) { + // get nodes in tree + var nodes = new Set([...this._tree.postorderTraversal()]); + // In order to access feature metadata for a given node, we need to // find the 0-based index in this._featureMetadataColumns that the // specified f.m. column corresponds to. (We *could* get around this by @@ -2403,15 +2445,19 @@ define([ var uniqueValueToFeatures = {}; _.each(fmObjs, function (mObj) { _.mapObject(mObj, function (fmRow, node) { + var fmVal = getValFromFM(fmRow); + if (!_.has(uniqueValueToFeatures, fmVal)) { + uniqueValueToFeatures[fmVal] = []; + } + // need to convert to integer node = parseInt(node); - // This is loosely based on how BIOMTable.getObsBy() works. - var fmVal = getValFromFM(fmRow); - if (_.has(uniqueValueToFeatures, fmVal)) { - uniqueValueToFeatures[fmVal].push(node); - } else { - uniqueValueToFeatures[fmVal] = [node]; + + // ignore nodes that have been sheared + if (!nodes.has(node)) { + return; } + uniqueValueToFeatures[fmVal].push(node); }); }); @@ -2457,7 +2503,6 @@ define([ var uniqueValueToFeatures = fmInfo.uniqueValueToFeatures; // convert observation IDs to _treeData keys. Notably, this includes // converting the values of uniqueValueToFeatures from Arrays to Sets. - var obs = {}; _.each(sortedUniqueValues, function (uniqueVal, i) { uniqueVal = sortedUniqueValues[i]; @@ -2472,11 +2517,18 @@ define([ undefined, reverse ); + // colors for drawing the tree var cm = colorer.getMapRGB(); + // colors for the legend var keyInfo = colorer.getMapHex(); + // if the tree has been sheared then categories in obs maybe empty. + // getUniqueFeatureMetadataInfo() does not filter out those categories + // so that the same color can be assigned to each value in obs. + util.removeEmptyArrayKeys(keyInfo, uniqueValueToFeatures); + // Do upwards propagation only if the coloring method is "tip" if (method === "tip") { obs = this._projectObservations(obs, false); @@ -2519,53 +2571,53 @@ define([ */ Empress.prototype._projectObservations = function (obs, ignoreAbsentTips) { var tree = this._tree, - categories = Object.keys(obs), - notRepresented = new Set(), - i, - j; - - if (!ignoreAbsentTips) { - // find "non-represented" tips - // Note: the following uses postorder traversal - for (i of this._tree.postorderTraversal()) { - if (tree.isleaf(tree.postorderselect(i))) { - var represented = false; - for (j = 0; j < categories.length; j++) { - if (obs[categories[j]].has(i)) { - represented = true; - break; - } - } - if (!represented) notRepresented.add(i); + nodeValue = [], + node, + category; + // set values for each node in obs + for (category in obs) { + for (node of obs[category]) { + if (nodeValue[node] === undefined) { + nodeValue[node] = category; + } else { + nodeValue[node] = null; } } } - // assign internal nodes to appropriate category based on children - // iterate using postorder - // Note that, although we don't explicitly iterate over the - // root (at index tree.size) in this loop, we iterate over all its - // descendants; so in the event that all leaves are unique, - // the root can still get assigned to a group. - for (i of this._tree.postorderTraversal()) { - var node = i; - var parent = tree.postorder(tree.parent(tree.postorderselect(i))); + for (node of this._tree.postorderTraversal()) { + var parent = tree.postorder( + tree.parent(tree.postorderselect(node)) + ); + if (nodeValue[node] === undefined && ignoreAbsentTips) { + continue; + } - for (j = 0; j < categories.length; j++) { - category = categories[j]; + if ( + nodeValue[parent] === undefined && + nodeValue[node] !== undefined + ) { + nodeValue[parent] = nodeValue[node]; + } else if ( + nodeValue[parent] !== nodeValue[node] || + nodeValue[node] === undefined + ) { + nodeValue[parent] = null; + } + } - // add internal nodes to groups - if (obs[category].has(node)) { - obs[category].add(parent); - } - if (notRepresented.has(node)) { - notRepresented.add(parent); + var result = {}; + for (node of this._tree.postorderTraversal(true)) { + category = nodeValue[node]; + if (category !== null && category !== undefined) { + if (result.hasOwnProperty(category)) { + result[category].add(node); + } else { + result[category] = new Set([node]); } } } - var result = util.keepUniqueKeys(obs, notRepresented); - // remove all groups that do not contain unique features result = _.pick(result, function (value, key) { return value.size > 0; @@ -2704,6 +2756,16 @@ define([ // stuff to only change whenever the tree is redrawn. this.thickenColoredNodes(this._currentLineWidth); + this.redrawBarPlotsToMatchLayout(); + this.centerLayoutAvgPoint(); + }; + + /** + * Redraw the barplot to match the current layout. If the current layout is + * "Unrooted" then this will remove the barplots from canvas. + * + */ + Empress.prototype.redrawBarPlotsToMatchLayout = function () { // Undraw or redraw barplots as needed (assuming barplots are supported // in the first place, of course; if no feature or sample metadata at // all was passed then barplots are not available :() @@ -2717,7 +2779,6 @@ define([ this.drawBarplots(); } } - this.centerLayoutAvgPoint(); }; /** @@ -2926,7 +2987,7 @@ define([ */ Empress.prototype.dontCollapseClade = function (clade) { var scope = this; - var nodes = this.getCladeNodes(parseInt(clade)); + var nodes = this._tree.getCladeNodes(parseInt(clade)); nodes.forEach(function (node) { scope._dontCollapse.add(node); }); @@ -3206,7 +3267,7 @@ define([ // step 1: find all nodes in the clade. // Note: cladeNodes is an array of nodes arranged in postorder fashion - var cladeNodes = this.getCladeNodes(rootNode); + var cladeNodes = this._tree.getCladeNodes(parseInt(rootNode)); // use the left most child in the clade to initialize currentCladeInfo var currentCladeInfo = { @@ -3304,44 +3365,6 @@ define([ this.drawTree(); }; - /** - * Returns all nodes in the clade whose root is node. - * - * Note: elements in the returned array are keys in this._treeData - * also, the returned array is sorted in a postorder fashion - * - * @param {Number} cladeRoot The root of the clade. An error is thrown if - * cladeRoot is not a valid node. - * - * @return {Array} The nodes in the clade - */ - Empress.prototype.getCladeNodes = function (cladeRoot) { - if (!this._treeData.hasOwnProperty(cladeRoot)) { - throw cladeRoot + " is not a valid node."; - } - // stores the clade nodes - var cladeNodes = []; - - // Nodes in the clade are found by performing a postorder traversal - // starting at the left most child of the clade and ending on cladeRoot - - // find left most child - // Note: initializing lchild as cladeRoot incase cladeRoot is a tip - var lchild = cladeRoot; - var fchild = this._tree.fchild(this._tree.postorderselect(cladeRoot)); - while (fchild !== 0) { - lchild = this._tree.postorder(fchild); - fchild = this._tree.fchild(this._tree.postorderselect(lchild)); - } - - // perform post order traversal until cladeRoot is reached. - for (var i = lchild; i <= cladeRoot; i++) { - cladeNodes.push(i); - } - - return cladeNodes; - }; - /** * Checks if the point (x, y) is within the bounds of the collapsed clade. * @@ -3517,7 +3540,7 @@ define([ for (var clade in this._collapsedClades) { if (this._isPointInClade(clade, point)) { var cladeNode = this._treeData[clade]; - return clade; + return parseInt(clade); } } return -1; @@ -3692,7 +3715,7 @@ define([ */ Empress.prototype.getTreeStats = function () { // Compute node counts - var allCt = this._tree.size; + var allCt = this._tree.currentSize; var tipCt = this._tree.getNumTips(this._tree.size); var intCt = allCt - tipCt; // Get length statistics @@ -3726,5 +3749,48 @@ define([ } }; + /** + * This will fill the autocomplete search bar with the names of the current + * tree. + */ + Empress.prototype.setAutoCompleteNames = function () { + var nodeNames = this._tree.getAllNames(); + // Don't include nodes with the name null (i.e. nodes without a + // specified name in the Newick file) in the auto-complete. + nodeNames = nodeNames.filter((n) => n !== null); + this._events.autocomplete(nodeNames); + }; + + /** + * This will shear/unshear + */ + Empress.prototype.shear = function (shearMap) { + this._tree.unshear(); + var scope = this; + var removeNodes = new Set(); + shearMap.forEach(function (values, cat) { + var fmInfo = scope.getUniqueFeatureMetadataInfo(cat, "tip"); + var uniqueValueToFeatures = fmInfo.uniqueValueToFeatures; + _.each(values, function (val) { + var obs = uniqueValueToFeatures[val]; + for (var node of obs) { + removeNodes.add(node); + } + }); + }); + + if (this.isCommunityPlot) { + this._biom.setIgnoreNodes(removeNodes); + } + + this._tree.shear(removeNodes); + + this.setAutoCompleteNames(); + + this.getLayoutInfo(); + + this.redrawBarPlotsToMatchLayout(); + }; + return Empress; }); diff --git a/empress/support_files/js/enable-disable-animation-tab.js b/empress/support_files/js/enable-disable-animation-tab.js new file mode 100644 index 00000000..c010956a --- /dev/null +++ b/empress/support_files/js/enable-disable-animation-tab.js @@ -0,0 +1,46 @@ +define(["EnableDisableTab"], function (EnableDisableTab) { + /** + * @class EnableDisableAnimationTab + * + * Adds the the ability to enable and disable the Animation tab by + * encapsulating it in an enabled/disabled container. + * Two new containers will be created: + * - an "enable container" that holds the original content of the tab + * - a "disable container" that will display a message describing why + * the tab has been disabled and how to re-enable it + * + * @param{object} tab The div container to encapsulate + * + * @returns{EnableDisableAnimationTab} + * @constructs EnableDisableAnimationTab + */ + function EnableDisableAnimationTab(tab) { + // call EnableDisableTab constructor + EnableDisableTab.call(this, tab); + + // add disable text message + this.disableContainer.innerHTML = + '

' + + "This tab has been disabled while an animation is active within " + + "the Emperor interface." + + "

"; + + // add instructions to re-enable the tab + this.disableContainer.innerHTML += + '

' + + "To re-enable this tab, " + + 'please click on the "Reset the animation" button located within ' + + 'the "Animations" tab of Emperor.' + + "

"; + } + + // inherit EnableDisableTab functions + EnableDisableAnimationTab.prototype = Object.create( + EnableDisableTab.prototype + ); + + // set EnableDisableAnimationTab's constructor + EnableDisableAnimationTab.prototype.constructor = EnableDisableAnimationTab; + + return EnableDisableAnimationTab; +}); diff --git a/empress/support_files/js/enable-disable-side-panel-tab.js b/empress/support_files/js/enable-disable-side-panel-tab.js new file mode 100644 index 00000000..84593bb1 --- /dev/null +++ b/empress/support_files/js/enable-disable-side-panel-tab.js @@ -0,0 +1,78 @@ +define(["EnableDisableTab"], function (EnableDisableTab) { + /** + * @class EnableDisableSidePanelTab + * + * Adds the the ability to enable and disable a side panel tab by + * encapsulating a side-panel tab in an enabled/disabled container. + * Two new containers will be created: + * - an "enable container" that holds the original content of the tab + * - a "disable container" that will display a message describing why + * the tab has been disabled and how to re-enable it + * + * @param{String} tabName The name of the tab + * @param{object} tab The div container to encapsulate + * @param{Boolean} isEmpirePlot true if we should show info about Empire + * plot animations, false otherwise + * + * @returns{EnableDisableSidePanelTab} + * @constructs EnableDisableSidePanelTab + */ + function EnableDisableSidePanelTab(tabName, tab, isEmpirePlot) { + // call EnableDisableTab constructor + EnableDisableTab.call(this, tab); + + // add disable text message + this.disableContainer.innerHTML = + '

' + + "This tab is disabled while an " + + "animation is active. " + + "To re-enable this tab, stop the animation." + + "

"; + + // add instructions on how to disable animations from empress + // We can use isEmpirePlot to shorten these messages if this isn't + // an Empire plot (because, in this case, there is only one possible + // type of animation that can happen and thus no reason to preface + // these instructions with "If this animation was started by Empress"). + var empressDisablingPrefix; + if (isEmpirePlot) { + // We include the "you" / "You" in these warnings so that the + // capitalization is correct in either case. + // In case the grammar police come knocking. + empressDisablingPrefix = + '' + + "If this animation was started by Empress, you "; + } else { + empressDisablingPrefix = "You "; + } + this.disableContainer.innerHTML += + '

' + + empressDisablingPrefix + + "can stop the animation by going to the " + + '"Animation" ' + + "tab and clicking on the " + + '"Stop Animation" button.' + + "

"; + + if (isEmpirePlot) { + // add instructions on how to disable animations from emperor + this.disableContainer.innerHTML += + '

' + + 'If this animation was started ' + + "by the Emperor interface of an Empire plot, " + + 'you can stop the animation by clicking on the "Restart the ' + + 'animation" button located within the "Animations" tab of Emperor.' + + "

"; + } + } + + // inherit EnableDisableTab functions + EnableDisableSidePanelTab.prototype = Object.create( + EnableDisableTab.prototype + ); + + // set EnableDisableSidePanelTab's constructor + EnableDisableSidePanelTab.prototype.constructor = EnableDisableSidePanelTab; + + return EnableDisableSidePanelTab; +}); diff --git a/empress/support_files/js/enable-disable-tab.js b/empress/support_files/js/enable-disable-tab.js new file mode 100644 index 00000000..52334af7 --- /dev/null +++ b/empress/support_files/js/enable-disable-tab.js @@ -0,0 +1,69 @@ +define([], function () { + /** + * @Abstract + * @class EnableDisableTab + * + * This is an abstract class that encapsulates a div container in an + * enabled/disabled container. + * Two new containers will be created: + * - an "enable container" that holds the original content of the tab + * - a "disable container" that will display a message describing why + * the tab has been disabled and how to re-enable it + * + * @param{object} tab The div container to encapsulate + * + * @returns{EnableDisableTab} + */ + function EnableDisableTab(tab) { + this._tab = tab; + + // capture contents of tab; + var content = this._tab.innerHTML; + + // clear contents of tab so that we can add the enable/disable + // containers + this._tab.innerHTML = ""; + + // create enable container + this.enableContainer = this._tab.appendChild( + document.createElement("div") + ); + this.enableContainer.innerHTML = content; + // this.enableContainer.classList.add("hidden"); + + // create Disable container + this.disableContainer = this._tab.appendChild( + document.createElement("div") + ); + + // add disable text message + this.disableContainer.classList.add("hidden"); + + if (this.constructor === EnableDisableTab) { + throw new Error( + "Abstract class EnableDisableTab cannot be instantiated." + ); + } + } + + /* + * Shows the enabled container which contains the content of the original + * tab. This method will also hide the disabled tab. + */ + EnableDisableTab.prototype.enableTab = function () { + this.enableContainer.classList.remove("hidden"); + this.disableContainer.classList.add("hidden"); + }; + + /** + * Shows the disabled container which will contain a message describing + * why the tab has been disabled and how to re-enable it. This method will + * also hide the enabled container. + */ + EnableDisableTab.prototype.disableTab = function () { + this.enableContainer.classList.add("hidden"); + this.disableContainer.classList.remove("hidden"); + }; + + return EnableDisableTab; +}); diff --git a/empress/support_files/js/legend.js b/empress/support_files/js/legend.js index 1c70308d..b10e3ae2 100644 --- a/empress/support_files/js/legend.js +++ b/empress/support_files/js/legend.js @@ -218,17 +218,8 @@ define(["jquery", "underscore", "util"], function ($, _, util) { * @param {Object} info Color key information. This should map unique * values (e.g. in sample or feature metadata) to * their assigned color, expressed in hex format. - * - * @throws {Error} If info has no keys. This check is done before anything - * else is done in this function. */ Legend.prototype.addCategoricalKey = function (name, info) { - if (_.isEmpty(info)) { - throw new Error( - "Can't create a categorical legend when there are no " + - "categories in the info" - ); - } this.clear(); this.addTitle(name); this._sortedCategories = util.naturalSort(_.keys(info)); diff --git a/empress/support_files/js/select-node-menu.js b/empress/support_files/js/select-node-menu.js index 635929ed..c9487bce 100644 --- a/empress/support_files/js/select-node-menu.js +++ b/empress/support_files/js/select-node-menu.js @@ -343,7 +343,6 @@ define(["underscore", "util"], function (_, util) { this.nodeNameLabel.textContent = "Name: " + name; } hide(this.nodeNameWarning); - // show either leaf or internal node var t = emp._tree; if (t.isleaf(t.postorderselect(this.nodeKeys[0]))) { @@ -431,7 +430,6 @@ define(["underscore", "util"], function (_, util) { } var name = this.empress.getNodeInfo(this.nodeKeys[0], "name"); - // Figure out whether or not we know the actual node in the tree (for // example, if the user searched for a node with a duplicate name, then // we don't know which node the user was referring to). This impacts diff --git a/empress/support_files/js/shearer.js b/empress/support_files/js/shearer.js new file mode 100644 index 00000000..d3f4827f --- /dev/null +++ b/empress/support_files/js/shearer.js @@ -0,0 +1,487 @@ +define(["underscore", "util", "TreeController"], function ( + _, + util, + TreeController +) { + /** + * Stores the next unique number for the removeLayer button is ShearLayer + */ + var UniqueRemoveNum = 0; + + /** + * Returns a unique number to use for the id of the removeLayer button. + */ + function getUniqueNum() { + return UniqueRemoveNum++; + } + + /** + * @class ShearLayer + * + * Create a new shear layer and adds it to the shear panel + */ + function ShearLayer( + fCol, + fVals, + container, + chkBxClickFunction, + removeClickFunction, + selectAllFunction, + unselectAllFuntion + ) { + this.fCol = fCol; + this.fVals = fVals; + this.container = container; + this.layerDiv = null; + this.inputs = []; + this.values = []; + + var scope = this; + + // create layer div + this.layerDiv = document.createElement("div"); + this.container.insertBefore(this.layerDiv, this.container.firstChild); + + // create border line + this.layerDiv.appendChild(document.createElement("hr")); + + // create checkbox legend title + var legendTitle = document.createElement("div"); + this.layerDiv.appendChild(legendTitle); + legendTitle.innerText = this.fCol; + legendTitle.classList.add("legend-title"); + + // // create container for select/unselect all buttons + var p = document.createElement("p"); + this.layerDiv.appendChild(p); + + // create the select all button + var button = document.createElement("button"); + button.innerText = "Select all"; + button.onclick = function () { + _.each(scope.inputs, function (input) { + input.select(); + }); + selectAllFunction(scope.fCol); + }; + button.setAttribute("style", "margin: 0 auto;"); + p.appendChild(button); + + // create the unselect all button + button = document.createElement("button"); + button.innerText = "Unselect all"; + button.onclick = function () { + _.each(scope.inputs, function (input) { + input.unselect(); + }); + unselectAllFuntion(scope.fCol, _.clone(scope.values)); + }; + button.setAttribute("style", "margin: 0 auto;"); + p.appendChild(button); + + // create checkbox legend div + var chkBoxLegendDiv = document.createElement("div"); + this.layerDiv.appendChild(chkBoxLegendDiv); + chkBoxLegendDiv.classList.add("barplot-layer-legend"); + chkBoxLegendDiv.classList.add("legend"); + + // create chcbox div + var legendChkBoxs = document.createElement("div"); + chkBoxLegendDiv.appendChild(legendChkBoxs); + + // create checkboxes + var table = document.createElement("table"); + legendChkBoxs.appendChild(table); + var uniqueNum = 1; + _.each(this.fVals, function (val) { + scope.values.push(val); + var row = document.createElement("tr"); + var id = + scope.fCol.replaceAll(" ", "-") + + "-" + + val.replaceAll(" ", "-") + + uniqueNum++; + + // add checkbox + var dataCheck = document.createElement("td"); + var input = document.createElement("input"); + input.id = id; + input.setAttribute("type", "checkbox"); + input.checked = true; + input.onchange = function () { + chkBxClickFunction(!input.checked, scope.fCol, val); + }; + + // the select/unselect functions that the "Select all" and + // "Unselect all" buttons will call + input.select = function () { + input.checked = true; + }; + input.unselect = function () { + input.checked = false; + }; + + scope.inputs.push(input); + dataCheck.appendChild(input); + row.appendChild(dataCheck); + + // add checkbox label + var dataLabel = document.createElement("label"); + dataLabel.setAttribute("for", input.id); + dataLabel.innerText = val; + var labelTD = document.createElement("td"); + labelTD.appendChild(dataLabel); + row.appendChild(labelTD); + + // add row to table + table.appendChild(row); + }); + + // create remove container + var removeContainer = document.createElement("p"); + this.layerDiv.appendChild(removeContainer); + + // create remove label + var removeLabel = document.createElement("label"); + removeLabel.innerText = "Remove this layer"; + removeContainer.appendChild(removeLabel); + + // create remove button + var removeButton = document.createElement("button"); + removeButton.id = "shear-layer-" + getUniqueNum() + "-delete"; + removeButton.innerText = "-"; + removeButton.onclick = function () { + removeClickFunction(scope.fCol); + scope.layerDiv.remove(); + scope.layerDiv = null; + }; + removeContainer.appendChild(removeButton); + + removeLabel.setAttribute("for", removeButton.id); + } + + /** + * @class ShearModel + * + * The model for Shearer. This model is responsible for maintaining updating + * empress whenever a user clicks on a shear option in one of the shear + * layers. This model is also responsible for notifying its observers + * whenever the shear status of the tree has changed. + */ + function ShearModel(empress, container) { + this.empress = empress; + this.layers = new Map(); + this.shearMap = new Map(); + this.container = container; + this.observers = []; + } + + /** + * Adds a shear layer to the shear panel. + * + * @param{String} layer The feature metadata column to create a shear layer + * from. + */ + ShearModel.prototype.addLayer = function (layer) { + var fVals = this.empress.getUniqueFeatureMetadataInfo(layer, "tip") + .sortedUniqueValues; + var layerObj = new ShearLayer( + layer, + fVals, + this.container, + (add, lyr, val) => { + ShearModel.addRemoveShearItem(this, add, lyr, val); + }, + (lyr) => { + ShearModel.removeLayer(this, lyr); + }, + (lyr) => { + ShearModel.clearShearMapLayer(this, lyr); + }, + (lyr, values) => { + ShearModel.setShearMapLayer(this, lyr, values); + } + ); + this.layers.set(layer, layerObj); + }; + + /** + * Returns the feature values the have been unselected (i.e. sheared) from + * a particular shear layer. + * + * @param{String} layer The name of shear layer + */ + ShearModel.prototype.getShearLayer = function (layer) { + return this.shearMap.get(layer); + }; + + /** + * Checks if a shear layer has been created for a particular feature metadata + * column. + * + * @param{String} layer The feature metadata column to check + */ + ShearModel.prototype.hasLayer = function (layer) { + return this.layers.has(layer); + }; + + /** + * Notifies all observers whenever the model has changed. + */ + ShearModel.prototype.notify = function () { + this.empress.shear(this.shearMap); + this.empress.drawTree(); + _.each(this.observers, function (obs) { + obs.shearUpdate(); + }); + }; + + /** + * Registers an observer to the model which will then be notified whenever + * the model is updated. Note this object must implement a shearUpdate() + * method. + * + * @param{Object} obs The object to register. A '.shearerObserverName' + * property must be provided if this observer will at + * some point be unregistered. + */ + ShearModel.prototype.registerObserver = function (obs) { + this.observers.push(obs); + }; + + /** + * Unregisters an observer to the model. The method will remove all + * observers with a '.shearerObserverName' === to removeObsName. + * + * @param{String} removeObsName The name of the observer to unregister. + */ + ShearModel.prototype.unregisterObserver = function (removeObsName) { + var removeIndx; + var tempObs = []; + _.each(this.observers, function (obs, indx) { + if ( + !obs.hasOwnProperty("shearerObserverName") || + obs.shearerObserverName !== removeObsName + ) { + tempObs.push(obs); + } + }); + this.observers = tempObs; + }; + + /** + * Removes a shear layer from a ShearModel + * @param{ShearModel} model The ShearModel to use + * @param{String} layer The name of layer to remove. + */ + ShearModel.removeLayer = function (model, layer) { + model.layers.delete(layer); + model.shearMap.delete(layer); + model.notify(); + }; + + /** + * Clears the shearMap. + * + * @param{ShearModel} model The ShearModel to use + * @param{String} layer The feature metadata column name of the shear layer + */ + ShearModel.clearShearMapLayer = function (model, layer) { + model.shearMap.set(layer, []); + model.notify(); + }; + + /** + * sets a shear layer within the shearMap. + * + * @param{ShearModel} model The ShearModel to use. + * @param{String} layer The feature metadata column name of the shear layer + * @param{Array} values An array of feature metadata value + */ + ShearModel.setShearMapLayer = function (model, layer, values) { + model.shearMap.set(layer, values); + model.notify(); + }; + + /** + * Adds or removes a shear value from a shear layer. + * @param{ShearModel} model The ShearModel to use. + * @param{Boolean} remove Whether or not to remove val from the shear layer + * @param{String} layer The name of feature metadata column of shear layer + * @param{String} val The feature metadata column value to add or remove + * from layer. + */ + ShearModel.addRemoveShearItem = function (model, remove, layer, val) { + if (remove) { + ShearModel.addShearItem(model, layer, val); + } else { + ShearModel.removeShearItem(model, layer, val); + } + }; + + /** + * Adds a shear value from a shear layer. + * @param{ShearModel} model The ShearModel to use. + * @param{String} layer The name of feature metadata column of shear layer + * @param{String} val The feature metadata column value to add or remove + * from layer. + */ + ShearModel.addShearItem = function (model, layer, val) { + if (model.shearMap.has(layer)) { + model.shearMap.get(layer).push(val); + } else { + model.shearMap.set(layer, [val]); + } + model.notify(); + }; + + /** + * Removes a shear value from a shear layer. + * @param{ShearModel} model The ShearModel to use. + * @param{String} layer The name of feature metadata column of shear layer + * @param{String} val The feature metadata column value to add or remove + * from layer. + */ + ShearModel.removeShearItem = function (model, layer, val) { + var items = model.getShearLayer(layer); + if (items === undefined) { + return; + } + var index = items.indexOf(val); + if (index > -1) { + items.splice(index, 1); + } + model.notify(); + }; + + /** + * @class ShearController + * + * The controller for a ShearModel. + */ + function ShearController(empress, container) { + this.model = new ShearModel(empress, container); + } + + /** + * Adds a layer to the model. + * @param{String} layer A feature metadata column name + */ + ShearController.prototype.addLayer = function (layer) { + if (!this.model.hasLayer(layer)) { + this.model.addLayer(layer); + } + }; + + /** + * Registers an observer to the model. + * + * @param{Object} obs The object to register to the model. A + * '.shearerObserverName' property must be provided if + * this observer will at some point be unregistered. + */ + ShearController.prototype.registerObserver = function (obs) { + this.model.registerObserver(obs); + }; + + /** + * Unregisters an observer to the model. The method will remove all + * observers with a '.shearerObserverName' === to removeObsName. + * + * @param{String} removeObsName The name of the observer to unregister. + */ + ShearController.prototype.unregisterObserver = function (removeObsName) { + this.model.unregisterObserver(removeObsName); + }; + + /** + * @class Shearer + * + * This is the exposed only exposed class of this closure and the one that + * the rest of the empress code base will interact with. + */ + + function Shearer(empress, fCols) { + this.fCols = fCols; + this.shearSelect = document.getElementById("shear-feature-select"); + this.addLayerButton = document.getElementById("shear-add-btn"); + this.shearLayerContainer = document.getElementById( + "shear-layer-container" + ); + this.controller = new ShearController( + empress, + this.shearLayerContainer + ); + + // this holds the 'Shear by...' select menu and the + // 'Add shear filter' button + this.shearOptionsContainer = document.getElementById( + "shear-add-options" + ); + + var scope = this; + _.each(this.fCols, function (col) { + var opt = document.createElement("option"); + opt.innerText = col; + opt.value = col; + scope.shearSelect.appendChild(opt); + }); + + this.addLayerButton.onclick = function () { + scope.controller.addLayer(scope.shearSelect.value); + scope.shearSelect[scope.shearSelect.selectedIndex].remove(); + // hide the 'Shear by...' menu and 'Add shear filter' button + // if the 'Shear by...' menu is empty + if (scope.shearSelect.options.length < 1) { + scope.shearOptionsContainer.classList.add("hidden"); + } + }; + } + + /** + * Add metadata values back into the shear select container. + */ + Shearer.prototype.shearUpdate = function () { + // clear select + this.shearSelect.innerHTML = ""; + + // add feature metadata values that do not have a layer + var scope = this; + _.each(this.fCols, function (col) { + if (!scope.controller.model.layers.has(col)) { + var opt = document.createElement("option"); + opt.innerText = col; + opt.value = col; + scope.shearSelect.appendChild(opt); + } + }); + // show the 'Shear by...' menu and 'Add shear filter' button + // if the 'Shear by...' menu is not empty + if (this.shearSelect.options.length >= 1) { + this.shearOptionsContainer.classList.remove("hidden"); + } + }; + + /** + * Registers an observer to the model. + * + * @param{Object} obs The object to register to the model. A + * '.shearerObserverName' property must be provided if + * this observer will at some point be unregistered. + */ + Shearer.prototype.registerObserver = function (obs) { + this.controller.registerObserver(obs); + }; + + /** + * Unregisters an observer to the model. The method will remove all + * observers with a '.shearerObserverName' === to removeObsName. + * + * @param{String} removeObsName The name of the observer to unregister. + */ + Shearer.prototype.unregisterObserver = function (removeObsName) { + this.controller.unregisterObserver(removeObsName); + }; + + return Shearer; +}); diff --git a/empress/support_files/js/side-panel-handler.js b/empress/support_files/js/side-panel-handler.js index c34692fc..304b81bc 100644 --- a/empress/support_files/js/side-panel-handler.js +++ b/empress/support_files/js/side-panel-handler.js @@ -302,6 +302,7 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { if (collapseChk.checked) { this.empress.collapseClades(); } + var lw = util.parseAndValidateNum(lwInput); this.empress.thickenColoredNodes(lw); @@ -316,6 +317,7 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { var col = this.sColor.value; var reverse = this.sReverseColor.checked; var keyInfo = this.empress.colorBySampleCat(colBy, col, reverse); + if (keyInfo === null) { util.toastMsg( "Sample metadata coloring error", @@ -334,12 +336,20 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { var col = this.fColor.value; var coloringMethod = this.fMethodChk.checked ? "tip" : "all"; var reverse = this.fReverseColor.checked; - this.empress.colorByFeatureMetadata( + var keyInfo = this.empress.colorByFeatureMetadata( colBy, col, coloringMethod, reverse ); + if (_.isEmpty(keyInfo)) { + util.toastMsg( + "Feature metadata coloring error", + "No nodes with feature metadata are visible due to shearing." + ); + this.fUpdateBtn.classList.remove("hidden"); + return; + } }; /** @@ -451,6 +461,19 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { } }; + /** + * This method is called whenever the empress tree is sheared + */ + SidePanel.prototype.shearUpdate = function () { + if (this.sChk.checked) { + this.sUpdateBtn.click(); + } + + if (this.fChk.checked) { + this.fUpdateBtn.click(); + } + }; + /** * Initializes exporting options. */ diff --git a/empress/support_files/js/tree-controller.js b/empress/support_files/js/tree-controller.js index d4f8d600..42a42d48 100644 --- a/empress/support_files/js/tree-controller.js +++ b/empress/support_files/js/tree-controller.js @@ -1,4 +1,4 @@ -define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { +define([], function () { function TreeModel(tree) { this.shearedTree = tree; this.fullTree = tree; @@ -44,6 +44,48 @@ define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { yield* nodes; }; + /** + * Returns all nodes in the clade whose root is node. + * + * Note: elements in the returned array are keys in this._treeData + * also, the returned array is sorted in a postorder fashion + * + * @param {Number} cladeRoot The root of the clade. An error is thrown if + * cladeRoot is not a valid node. + * + * @return {Array} The nodes in the clade + */ + TreeModel.prototype.getCladeNodes = function (cladeRoot) { + cladeRoot = this.fullToSheared.get(cladeRoot); + if (cladeRoot === undefined) { + throw cladeRoot + " is not a valid node."; + } + // stores the clade nodes + var cladeNodes = []; + + // Nodes in the clade are found by performing a postorder traversal + // starting at the left most child of the clade and ending on cladeRoot + + // find left most child + // Note: initializing lchild as cladeRoot incase cladeRoot is a tip + var lchild = cladeRoot; + var fchild = this.shearedTree.fchild( + this.shearedTree.postorderselect(cladeRoot) + ); + while (fchild !== 0) { + lchild = this.shearedTree.postorder(fchild); + fchild = this.shearedTree.fchild( + this.shearedTree.postorderselect(lchild) + ); + } + + // perform post order traversal until cladeRoot is reached. + for (var i = lchild; i <= cladeRoot; i++) { + cladeNodes.push(this.shearedToFull.get(i)); + } + return cladeNodes; + }; + function TreeController(tree) { /** * @@ -76,6 +118,7 @@ define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { */ this.model = new TreeModel(tree); this.size = this.model.fullTree.size; + this.currentSize = this.model.shearedTree.size; } /** @@ -95,6 +138,7 @@ define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { */ TreeController.prototype.shear = function (tips) { this.model.shear(tips); + this.currentSize = this.model.shearedTree.size; }; /** @@ -102,6 +146,7 @@ define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { */ TreeController.prototype.unshear = function () { this.model.unshear(); + this.currentSize = this.model.shearedTree.size; }; /** @@ -471,5 +516,20 @@ define(["LayoutsUtil", "Colorer"], function (LayoutsUtil, Colorer) { return nodes; }; + /** + * Returns all nodes in the clade whose root is node. + * + * Note: elements in the returned array are keys in this._treeData + * also, the returned array is sorted in a postorder fashion + * + * @param {Number} cladeRoot The root of the clade. An error is thrown if + * cladeRoot is not a valid node. + * + * @return {Array} The nodes in the clade + */ + TreeController.prototype.getCladeNodes = function (cladeRoot) { + return this.model.getCladeNodes(cladeRoot); + }; + return TreeController; }); diff --git a/empress/support_files/js/util.js b/empress/support_files/js/util.js index 25299fd9..9ada67bc 100644 --- a/empress/support_files/js/util.js +++ b/empress/support_files/js/util.js @@ -282,6 +282,22 @@ define(["underscore", "toastr"], function (_, toastr) { return [fm2length, valMin, valMax]; } + /** + * Given two Objects that map keys to arrays, removes all keys from object + * "a" that are present in object "b" and point to a zero-length array in + * "b". + * + * @param {Object} a The object from which keys will be removed. + * @param {Object} b The object used as a guide to remove keys from a. + */ + function removeEmptyArrayKeys(a, b) { + for (var key in b) { + if (b[key].length === 0) { + delete a[key]; + } + } + } + return { keepUniqueKeys: keepUniqueKeys, naturalSort: naturalSort, @@ -290,5 +306,6 @@ define(["underscore", "toastr"], function (_, toastr) { parseAndValidateNum: parseAndValidateNum, toastMsg: toastMsg, assignBarplotLengths: assignBarplotLengths, + removeEmptyArrayKeys: removeEmptyArrayKeys, }; }); diff --git a/empress/support_files/templates/empress-template.html b/empress/support_files/templates/empress-template.html index d3d14e8d..f7172fc9 100644 --- a/empress/support_files/templates/empress-template.html +++ b/empress/support_files/templates/empress-template.html @@ -114,7 +114,12 @@ 'util' : './js/util', 'LayoutsUtil': './js/layouts-util', 'ExportUtil': './js/export-util', - 'TreeController': './js/tree-controller' + 'TreeController': './js/tree-controller', + 'Shearer': './js/shearer', + 'EnableDisableTab': './js/enable-disable-tab', + 'EnableDisableSidePanelTab': './js/enable-disable-side-panel-tab', + 'EnableDisableAnimationTab': './js/enable-disable-animation-tab', + } }); @@ -123,30 +128,67 @@ 'Drawer', 'SidePanel', 'AnimationPanel', 'Animator', 'BarplotLayer', 'BarplotPanel', 'BIOMTable', 'Empress', 'Legend', 'Colorer', 'VectorOps', 'CanvasEvents', - 'SelectedNodeMenu', 'util', 'LayoutsUtil', 'ExportUtil'], + 'SelectedNodeMenu', 'util', 'LayoutsUtil', 'ExportUtil', + 'Shearer', 'EnableDisableSidePanelTab', + 'EnableDisableAnimationTab'], function($, gl, chroma, underscore, spectrum, toastr, filesaver, ByteArray, BPTree, Camera, Drawer, SidePanel, AnimationPanel, Animator, BarplotLayer, BarplotPanel, BIOMTable, Empress, Legend, Colorer, VectorOps, CanvasEvents, SelectedNodeMenu, - util, LayoutsUtil, ExportUtil) { + util, LayoutsUtil, ExportUtil, Shearer, + EnableDisableSidePanelTab, EnableDisableAnimationTab) { + // NOTE: the contents of this line are validated in the python + // integration tests. If this line is changed somehow, the integration + // tests will need to be updated accordingly. + var isCommunityPlot = {{ is_community_plot | tojson }}; + + var isEmpirePlot = {{ is_empire_plot | tojson }}; + + // create EnableDisableSidePanelTabs to disable when animation is + // running. + // This must be done first so that everything else is linked properly. + // For example button click events will no longer work properly. + if (isCommunityPlot) { + var tabs = [ + { + tabName: "Sample Metadata Coloring", + container: document.getElementById( + "sample-metadata-coloring-div") + }, + { + tabName: "Feature Metadata Coloring", + container: document.getElementById( + "feature-metadata-coloring-div") + }, + { + tabName: "Shear Tree", + container: document.getElementById("shear-div") + }, + ]; + var sidePanelTabs = []; + // wrap the sidepanel tabs in a EnableDisableSidePanelTab + _.each(tabs, function(tabInfo) { + sidePanelTabs.push( + new EnableDisableSidePanelTab(tabInfo.tabName, + tabInfo.container, isEmpirePlot) + ); + }); + var animationTab = new EnableDisableAnimationTab( + document.getElementById("animation-div") + ); + } // initialze the tree and model var tree = new BPTree( {{ tree }}, {{ names | tojson }}, {{ lengths | tojson }} ); - var fmCols = {{ feature_metadata_columns | tojson }}; var splitTaxonomyCols = {{ split_taxonomy_columns | tojson }}; var canvas = document.getElementById('tree-surface'); - // NOTE: the contents of this line are validated in the python - // integration tests. If this line is changed somehow, the integration - // tests will need to be updated accordingly. - var isCommunityPlot = {{ is_community_plot | tojson }}; - var biom = null; if (isCommunityPlot) { biom = new BIOMTable( @@ -177,16 +219,22 @@ sPanel.addLayoutTab(); sPanel.addExportTab(); + var shearer = new Shearer( + empress, + empress.getFeatureMetadataCategories(), + ); + shearer.registerObserver(sPanel); + // Only show the sample metadata coloring / animation panels if a // feature table and sample metadata file were provided if (isCommunityPlot) { sPanel.addSampleTab(); // Create animator state machine - var animator = new Animator(empress); + var animator = new Animator(empress, sidePanelTabs); // Add animator GUI components - var animationPanel = new AnimationPanel(animator); + var animationPanel = new AnimationPanel(animator, animationTab); animationPanel.addAnimationTab(); document.getElementById("animationOpenButton").classList .remove("hidden"); @@ -208,6 +256,17 @@ $(".needs-feature-metadata").addClass("hidden"); } + // Here we register the stats button to the shearer so that the tree + // stats are updated whenever the tree is sheared. + var statsButton = document.getElementById("stats-btn"); + statsButton.shearUpdate = () => { + sPanel.populateTreeStats(); + statsButton.classList.remove("unpopulated"); + }; + shearer.registerObserver(shearer); + shearer.registerObserver(statsButton); + + // make all tabs collapsable document.querySelectorAll(".collapsible").forEach(function(btn) { btn.addEventListener("click", function() { diff --git a/empress/support_files/templates/side-panel.html b/empress/support_files/templates/side-panel.html index 2a59552f..b48a008f 100644 --- a/empress/support_files/templates/side-panel.html +++ b/empress/support_files/templates/side-panel.html @@ -15,7 +15,7 @@ -