From c556cc29ae3e7189e9e595833c65f93dc2c41d5b Mon Sep 17 00:00:00 2001 From: kcantrel Date: Thu, 12 Aug 2021 14:12:30 -0700 Subject: [PATCH 1/9] basic version of lca --- .../js/abstract-observer-pattern.js | 60 ++++++ empress/support_files/js/bp-tree.js | 75 ++++++- empress/support_files/js/canvas-events.js | 13 +- empress/support_files/js/drawer.js | 33 +++ empress/support_files/js/empress.js | 192 ++++++++++++++++- empress/support_files/js/path-selector.js | 58 +++++ .../support_files/js/side-panel-handler.js | 31 +-- empress/support_files/js/tree-controller.js | 49 +++++ empress/support_files/js/util.js | 24 +++ .../templates/empress-template.html | 2 + .../support_files/templates/side-panel.html | 26 +++ tests/index.html | 9 +- tests/test-bp-tree.js | 200 +++++++++++++++++- tests/test-empress.js | 20 ++ tests/test-tree-controller.js | 62 +++++- tests/utilities-for-testing.js | 8 +- 16 files changed, 819 insertions(+), 43 deletions(-) create mode 100644 empress/support_files/js/abstract-observer-pattern.js create mode 100644 empress/support_files/js/path-selector.js diff --git a/empress/support_files/js/abstract-observer-pattern.js b/empress/support_files/js/abstract-observer-pattern.js new file mode 100644 index 00000000..5f6739b9 --- /dev/null +++ b/empress/support_files/js/abstract-observer-pattern.js @@ -0,0 +1,60 @@ +define(["underscore"], function (_) { + /** + * @abstract + * @class AbstractObserverPattern + * + * From Wikipedia: https://en.wikipedia.org/wiki/Observer_pattern + * The observer pattern is a software design pattern in which an object, + * named the 'Subject', maintains a list of its dependents, called + * 'Observers',and notifies them automatically of any state changes, usually + * by calling one of their methods. + * + * Any class that inherits AbstractObserverPattern will become a 'Subject'. + * + * @param{String} notifyFunction The function that Subject will call on all + * of its observers. observers must implement + * this function in order to register as an + * observer. + */ + class AbstractObserverPattern { + constructor(notifyFunction) { + if (this.constructor === AbstractObserverPattern) { + throw new Error( + "Abstract class: " + + "AbstractObserverPattern cannot be instantiated." + ); + } + + this.observers = []; + this.notifyFunction = notifyFunction; + } + + /* + * Adds an observer to the observer list. + */ + registerObserver(observer) { + if (!this.hasNotifyFunction(observer)) { + throw new Error( + "Cannot register observer: missing " + this.notifyFunction + ); + } + this.observers.push(observer); + } + + /** + * Notify all observers. + */ + notify(data) { + var scope = this; + _.each(this.observers, function (obs) { + obs[scope.notifyFunction](data); + }); + } + + hasNotifyFunction(observer) { + return typeof observer[this.notifyFunction] === "function"; + } + } + + return AbstractObserverPattern; +}); diff --git a/empress/support_files/js/bp-tree.js b/empress/support_files/js/bp-tree.js index a5de5135..11baa4b8 100644 --- a/empress/support_files/js/bp-tree.js +++ b/empress/support_files/js/bp-tree.js @@ -301,10 +301,17 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { * @return{Number} The excess at position i * @private */ - BPTree.prototype.excess_ = function (i) { + BPTree.prototype.excess = function (i) { // need to subtract 1 since i starts at 0 // Note: rank(1,i) - rank(0,i) = (2*(rank(1,i)) - i - return 2 * this.r1Cache_[i] - i - 1; + // if (i < 0) { + // return 0; + // } + // return 2 * this.r1Cache_[i] - i - 1; + + // this method is used frequently and thus has been updated + // to use a cached version + return this.eCache_[i]; }; /** @@ -1103,5 +1110,69 @@ define(["ByteArray", "underscore"], function (ByteArray, _) { }; }; + /* + * Finds the minimum excess between i and j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The miniumn excess between i and j. + */ + BPTree.prototype.rmq = function (i, j) { + if (j < i) { + var temp = j; + j = i; + i = temp; + } + var minK = i; + var minV = this.excess(i); + for (var k = i; k < j + 1; k++) { + var obsV = this.excess(k); + if (obsV < minV) { + minK = k; + minV = obsV; + } + } + return minK; + }; + + /* + * Test to see if i if the ancestor of j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return true if i is the ancestor of j, false otherwise. + */ + BPTree.prototype.isAncestor = function (i, j) { + if (i === j) { + return false; + } + + if (!this.b_[i]) { + i = this.open(i); + } + + return i <= j && j < this.close(i); + }; + + /* + * Finds the lowest common ancestor of i and j + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The index of the lca of i and j. + */ + BPTree.prototype.lca = function (i, j) { + if (this.isAncestor(i, j)) { + return i; + } else if (this.isAncestor(j, i)) { + return j; + } else { + return this.parent(this.rmq(i, j) + 1); + } + }; + return BPTree; }); diff --git a/empress/support_files/js/canvas-events.js b/empress/support_files/js/canvas-events.js index ecf50ed7..ec9ca382 100644 --- a/empress/support_files/js/canvas-events.js +++ b/empress/support_files/js/canvas-events.js @@ -137,6 +137,7 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( // removes the selected node menu if the mouseMove flag is not set var mouseClick = function (e) { + var shiftPressed = e.shiftKey; if (!scope.mouseMove) { // clear old select menu selectedNodeMenu.clearSelectedNode(); @@ -201,7 +202,8 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( scope.placeNodeSelectionMenu( empress.getNodeInfo(closeNode, "name"), false, - closeNode + closeNode, + shiftPressed ); } } @@ -419,8 +421,15 @@ define(["underscore", "glMatrix", "SelectedNodeMenu"], function ( CanvasEvents.prototype.placeNodeSelectionMenu = function ( nodeName, moveTree, - nodeKey + nodeKey, + shiftPressed = false ) { + if (shiftPressed) { + this.empress.setSelectedNode(nodeKey); + this.selectedNodeMenu.setSelectedNodes([nodeKey]); + this.empress.drawTree(); + return; + } var scope = this; var node; /** diff --git a/empress/support_files/js/drawer.js b/empress/support_files/js/drawer.js index 8b304f99..64ed092c 100644 --- a/empress/support_files/js/drawer.js +++ b/empress/support_files/js/drawer.js @@ -138,6 +138,14 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( s.treeColorBuff = c.createBuffer(); this.treeColorSize = 0; + // buffer object for path coordinates + s.pathCoordBuff = c.createBuffer(); + this.pathCoordSize = 0; + + // buffer object to store path color + s.pathColorBuff = c.createBuffer(); + this.pathColorSize = 0; + // buffer object used to thicken node lines s.thickNodeBuff = c.createBuffer(); this.thickNodeSize = 0; @@ -315,6 +323,27 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( this.fillBufferData_(this.sProg_.treeColorBuff, data); }; + /** + * Fills the buffer used to draw the selected path. + * + * @param {Array} data The coordinates [x, y, ...] to fill pathCoordBuff + */ + Drawer.prototype.loadPathCoordsBuff = function (data) { + data = new Float32Array(data); + this.pathCoordSize = data.length / 2; + this.fillBufferData_(this.sProg_.pathCoordBuff, data); + }; + + /** + * Fills the buffer used to draw the selected path. + * + * @param {Array} data The color data to fill pathColorBuff + */ + Drawer.prototype.loadPathColorBuff = function (data) { + data = new Float32Array(data); + this.pathColorSize = data.length; + this.fillBufferData_(this.sProg_.pathColorBuff, data); + }; /** * Fills the buffer used to thicken node lines * @@ -414,6 +443,10 @@ define(["underscore", "glMatrix", "Camera", "Colorer"], function ( this.bindBuffer(s.treeColorBuff, 3, 1); c.drawArrays(c.LINES, 0, this.treeCoordSize); + this.bindBuffer(s.pathCoordBuff, 2, 2); + this.bindBuffer(s.pathColorBuff, 3, 1); + c.drawArrays(c.LINES, 0, this.pathCoordSize); + this.bindBuffer(s.thickNodeBuff, 1, 3); c.drawArrays(c.TRIANGLES, 0, this.thickNodeSize); diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index fc803003..c891a180 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -12,6 +12,7 @@ define([ "LayoutsUtil", "ExportUtil", "TreeController", + "PathSelector", ], function ( _, Camera, @@ -25,7 +26,8 @@ define([ chroma, LayoutsUtil, ExportUtil, - TreeController + TreeController, + PathSelector ) { /** * @class EmpressTree @@ -387,6 +389,9 @@ define([ * for clades in this array that share the same group membership. */ this._group = new Array(this._tree.size + 1).fill(-1); + + this.pathSelector = new PathSelector(); + this.pathSelector.registerObserver(this); } /** @@ -3792,5 +3797,190 @@ define([ this.redrawBarPlotsToMatchLayout(); }; + /* + * Finds the lowest comman ancestor of the input nodes. + * + * @param{Array} nodes An array of node keys. + * + * @return The node key of the lca to nodes. + */ + Empress.prototype.findLCA = function (nodes) { + var scope = this; + // 1: find least common ancestor. + var nodeBPIndices = _.map(nodes, function (node) { + return scope._tree.postorderselect(node); + }); + var lcaBPIndx = this._tree.lca(nodeBPIndices[0], nodeBPIndices[1]); + nodeBPIndices.shift(); // removes first nodes + nodeBPIndices.shift(); // removes second node + + while (nodeBPIndices.length > 0) { + lcaBPIndx = this._tree.lca(lcaBPIndx, nodeBPIndices.shift()); + } + var lcaNode = this._tree.postorder(lcaBPIndx); + return lcaNode; + }; + + /* + * Colors the tree by highlighting the patch of nodes to the lca. + * + * @param{Array} nodes An array of node keys. + * @param{Array} lca The node key of the lca. + */ + Empress.prototype.colorLCAPath = function (nodes, lca) { + var lcaBPIndx = this._tree.postorderselect(lca); + + var coordsBuff = []; + var colorsBuff = []; + var scope = this; + var color = Colorer.rgbToFloat([64, 200, 64]); + addColorToBuff = () => { + colorsBuff.push(color, color); + colorsBuff.push(color, color); + }; + var addCoordsToBuff = (node, parent) => { + if (this._currentLayout === "Unrooted") { + coordsBuff.push(this.getX(parent), this.getY(parent)); + coordsBuff.push(this.getX(node), this.getY(node)); + addColorToBuff(); + } else if (this._currentLayout === "Rectangular") { + // draw horizontal line from child x, y to parent x, child y + coordsBuff.push(this.getX(node), this.getY(node)); + coordsBuff.push(this.getX(parent), this.getY(node)); + addColorToBuff(); + + // draw vertical line from parent x, y to parent x child y + coordsBuff.push(this.getX(parent), this.getY(parent)); + coordsBuff.push(this.getX(parent), this.getY(node)); + addColorToBuff(); + } else if (this._currentLayout === "Circular") { + // draw arc from parent to child + var pAngle = this.getNodeInfo(parent, "angle"); + var nAngle = this.getNodeInfo(node, "angle"); + var arcDelta = nAngle - pAngle; + + var numSamples = this._numSampToApproximate(arcDelta); + var sampleAngle = arcDelta / numSamples; + var sX = this.getX(parent); + var sY = this.getY(parent); + var x, y; + for (var line = 0; line < numSamples; line++) { + x = + sX * Math.cos(line * sampleAngle) - + sY * Math.sin(line * sampleAngle); + y = + sX * Math.sin(line * sampleAngle) + + sY * Math.cos(line * sampleAngle); + coordsBuff.push(x, y); + + x = + sX * Math.cos((line + 1) * sampleAngle) - + sY * Math.sin((line + 1) * sampleAngle); + y = + sX * Math.sin((line + 1) * sampleAngle) + + sY * Math.cos((line + 1) * sampleAngle); + coordsBuff.push(x, y); + addColorToBuff(); + } + + // draw line connect node x,y to parent arc + coordsBuff.push(this.getX(node), this.getY(node)); + coordsBuff.push(x, y); + addColorToBuff(); + } + }; + for (var node of nodes) { + // this.setNodeInfo(node, "color", Colorer.rgbToFloat([64, 200, 64])); + var parentBPIndx = this._tree.parent( + this._tree.postorderselect(node) + ); + var parent = this._tree.postorder(parentBPIndx); + while (parentBPIndx !== lcaBPIndx) { + // && parentBPIndx !== this._tree.root()) { + // add buff info + addCoordsToBuff(node, parent); + + // progress nodes + node = parent; + parentBPIndx = this._tree.parent(parentBPIndx); + parent = this._tree.postorder(parentBPIndx); + // this.setNodeInfo(parent, "color", Colorer.rgbToFloat([64, 200, 64])); + } + addCoordsToBuff(node, parent); + } + this._drawer.loadPathCoordsBuff(coordsBuff); + this._drawer.loadPathColorBuff(colorsBuff); + this.drawTree(); + }; + + /* + * Calculates the distance of the path connecting nodes to the lca. + * + * @param{Array} nodes An array of node keys. + * @param{Array} lca The node key of the lca. + * @return The distance. + */ + Empress.prototype.calcLCADistance = function (nodes, lca) { + var dist = 0; + var lcaBPIndx = this._tree.postorderselect(lca); + + // find unique nodes + var uniqueNodeBPIndices = new Set(); + for (var node of nodes) { + var nodeBPIndx = this._tree.postorderselect(node); + while (nodeBPIndx !== lcaBPIndx) { + uniqueNodeBPIndices.add(nodeBPIndx); + nodeBPIndx = this._tree.parent(nodeBPIndx); + } + } + + // sum distance + for (var nodeIdx of uniqueNodeBPIndices) { + dist += this._tree.length(nodeIdx); + } + + return dist; + }; + + /* + * Attempts to a node to the selected path. If the selected path is maxed + * out, the a warning message will appear. + * + * @param{Number} node A node key + */ + Empress.prototype.setSelectedNode = function (node) { + var name = this.getName(node); + this.pathSelector.addNode(node, name); + }; + + /* + * notify function for PathSelector. If nodes contains 2 or more nodes + * then empress will highlight the selected path. Otherwire, empress + * will reset the selected path. + * + * @param{Array} nodes An array of node keys. + */ + Empress.prototype.pathSelectorUpdate = function (nodes) { + if (nodes.length > 1) { + // 1: find lowest common ancestor. + var lcaNode = this.findLCA(nodes); + var lcaBPIndx = this._tree.postorderselect(lcaNode); + + // 2: color branches leading from nodes to lca + this.colorLCAPath(nodes, lcaNode); + + // 3: draw tree + this.drawTree(); + + // return distances + var dist = this.calcLCADistance(nodes, lcaNode); + this.pathSelector.addDistance(dist); + } else { + this._drawer.loadPathCoordsBuff([]); + this._drawer.loadPathColorBuff([]); + this.drawTree(); + } + }; + return Empress; }); diff --git a/empress/support_files/js/path-selector.js b/empress/support_files/js/path-selector.js new file mode 100644 index 00000000..3e07c1ed --- /dev/null +++ b/empress/support_files/js/path-selector.js @@ -0,0 +1,58 @@ +define(["util", "AbstractObserverPattern"], function ( + util, + AbstractObserverPattern +) { + class PathSelector extends AbstractObserverPattern { + constructor() { + super("pathSelectorUpdate"); + this.selectedNodeIDs = []; + this.selectedNodeNames = []; + this.n1Container = document.getElementById("shortest-path-n1-div"); + this.n2Container = document.getElementById("shortest-path-n2-div"); + this.distContainer = document.getElementById( + "shortest-path-distance-div" + ); + this.resetBtn = document.getElementById("reset-shorest-path"); + this.resetBtn.onclick = () => { + this.reset(); + }; + } + + addNode(nodeId, nodeName) { + if (this.selectedNodeIDs.length >= 2) { + util.toastMsg( + "test", + "Max nodes selected. Press the reset to clear the selected" + + "nodes.", + 3000, + "warning" + ); + return; + } + + this.selectedNodeIDs.push(nodeId); + this.selectedNodeNames.push(nodeName); + if (this.selectedNodeIDs.length === 1) { + this.n1Container.innerText = nodeName; + } else { + this.n2Container.innerText = nodeName; + this.notify(this.selectedNodeIDs); + } + } + + addDistance(distance) { + util.populateNum("shortest-path-distance-div", distance); + } + + reset() { + this.selectedNodeIDs = []; + this.selectedNodeNames = []; + this.n1Container.innerText = ""; + this.n2Container.innerText = ""; + this.distContainer.innerText = ""; + this.notify([]); + } + } + + return PathSelector; +}); diff --git a/empress/support_files/js/side-panel-handler.js b/empress/support_files/js/side-panel-handler.js index 5a50128e..25787537 100644 --- a/empress/support_files/js/side-panel-handler.js +++ b/empress/support_files/js/side-panel-handler.js @@ -668,32 +668,13 @@ define(["underscore", "Colorer", "util"], function (_, Colorer, util) { * https://stackoverflow.com/a/31581206/10730311. */ SidePanel.prototype.populateTreeStats = function () { - // Formats a number with just toLocaleString() (leaving the locale - // unspecified should mean the user's settings are respected). - // For English, at least, this should mean that numbers are formatted - // with commas as thousands separators (e.g. 12,345). - var populateNum = function (htmlID, val, localeOptions) { - document.getElementById(htmlID).textContent = val.toLocaleString( - undefined, - localeOptions - ); - }; - - // Formats a number with toLocaleString(), and also limits - // the number to 4 digits after the decimal point. - var populateFloat = function (htmlID, val) { - populateNum(htmlID, val, { - minimumFractionDigits: 0, - maximumFractionDigits: 4, - }); - }; var stats = this.empress.getTreeStats(); - populateNum("stats-tip-count", stats.tipCt); - populateNum("stats-int-count", stats.intCt); - populateNum("stats-total-count", stats.allCt); - populateFloat("stats-min-length", stats.min); - populateFloat("stats-max-length", stats.max); - populateFloat("stats-avg-length", stats.avg); + util.populateNum("stats-tip-count", stats.tipCt); + util.populateNum("stats-int-count", stats.intCt); + util.populateNum("stats-total-count", stats.allCt); + util.populateFloat("stats-min-length", stats.min); + util.populateFloat("stats-max-length", stats.max); + util.populateFloat("stats-avg-length", stats.avg); }; return SidePanel; diff --git a/empress/support_files/js/tree-controller.js b/empress/support_files/js/tree-controller.js index 42a42d48..c513433a 100644 --- a/empress/support_files/js/tree-controller.js +++ b/empress/support_files/js/tree-controller.js @@ -531,5 +531,54 @@ define([], function () { return this.model.getCladeNodes(cladeRoot); }; + /* + * Test to see if i if the ancestor of j. + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return true if i is the ancestor of j, false otherwise. + */ + TreeController.prototype.isAncestor = function (i, j) { + var shearedTree = this.model.shearedTree; + var fullTree = this.model.fullTree; + + var nodeI = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(i)) + ); + var nodeJ = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(j)) + ); + + result = shearedTree.isAncestor(nodeI, nodeJ); + return result; + }; + + /* + * Finds the lowest common ancestor of i and j + * + * @param {Number} i The ith index of the bp array. + * @param {Number} j The jth index of the bp array. + * + * @return The index of the lca of i and j. + */ + TreeController.prototype.lca = function (i, j) { + var shearedTree = this.model.shearedTree; + var fullTree = this.model.fullTree; + + var nodeI = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(i)) + ); + var nodeJ = shearedTree.postorderselect( + this.model.fullToSheared.get(fullTree.postorder(j)) + ); + + resultNode = shearedTree.postorder(shearedTree.lca(nodeI, nodeJ)); + resultNode = fullTree.postorderselect( + this.model.shearedToFull.get(resultNode) + ); + return resultNode; + }; + return TreeController; }); diff --git a/empress/support_files/js/util.js b/empress/support_files/js/util.js index 9ada67bc..d3ce96c8 100644 --- a/empress/support_files/js/util.js +++ b/empress/support_files/js/util.js @@ -298,6 +298,28 @@ define(["underscore", "toastr"], function (_, toastr) { } } + /* Formats a number with just toLocaleString() (leaving the locale + * unspecified should mean the user's settings are respected). + * For English, at least, this should mean that numbers are formatted + * with commas as thousands separators (e.g. 12,345). + */ + function populateNum(htmlID, val, localeOptions) { + document.getElementById(htmlID).textContent = val.toLocaleString( + undefined, + localeOptions + ); + } + + /* Formats a number with toLocaleString(), and also limits + * the number to 4 digits after the decimal point. + */ + function populateFloat(htmlID, val) { + populateNum(htmlID, val, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }); + } + return { keepUniqueKeys: keepUniqueKeys, naturalSort: naturalSort, @@ -307,5 +329,7 @@ define(["underscore", "toastr"], function (_, toastr) { toastMsg: toastMsg, assignBarplotLengths: assignBarplotLengths, removeEmptyArrayKeys: removeEmptyArrayKeys, + populateNum: populateNum, + populateFloat: populateFloat, }; }); diff --git a/empress/support_files/templates/empress-template.html b/empress/support_files/templates/empress-template.html index f7172fc9..c1688ea6 100644 --- a/empress/support_files/templates/empress-template.html +++ b/empress/support_files/templates/empress-template.html @@ -119,6 +119,8 @@ 'EnableDisableTab': './js/enable-disable-tab', 'EnableDisableSidePanelTab': './js/enable-disable-side-panel-tab', 'EnableDisableAnimationTab': './js/enable-disable-animation-tab', + 'AbstractObserverPattern': './js/abstract-observer-pattern', + 'PathSelector': './js/path-selector', } }); diff --git a/empress/support_files/templates/side-panel.html b/empress/support_files/templates/side-panel.html index 92bc82a2..37ade480 100644 --- a/empress/support_files/templates/side-panel.html +++ b/empress/support_files/templates/side-panel.html @@ -450,6 +450,32 @@

+ + + - + + +
+
+
+ {{ emperor_base_dependencies }} @@ -78,6 +80,10 @@
+ + + + @@ -172,6 +177,13 @@
+ + + + + +