diff --git a/docs/moving-pictures/empress-tree.qzv b/docs/moving-pictures/empress-tree.qzv index 9b224e5d1..fc4586fc3 100644 Binary files a/docs/moving-pictures/empress-tree.qzv and b/docs/moving-pictures/empress-tree.qzv differ diff --git a/empress/core.py b/empress/core.py index f81143b49..3a535ad89 100644 --- a/empress/core.py +++ b/empress/core.py @@ -228,10 +228,23 @@ def _to_dict(self): ycoord = "y" + layoutsuffix tree_data[i][xcoord] = getattr(node, xcoord) tree_data[i][ycoord] = getattr(node, ycoord) - # Also add vertical bar coordinate info for the rectangular layout + # Hack: it isn't mentioned above, but we need start pos info for + # circular layout. The start pos for the other layouts is the + # parent xy coordinates so we need only need to specify the start + # for circular layout. + tree_data[i]["xc0"] = node.xc0 + tree_data[i]["yc0"] = node.yc0 + + # Also add vertical bar coordinate info for the rectangular layout, + # and start point & arc coordinate info for the circular layout if not node.is_tip(): tree_data[i]["highestchildyr"] = node.highestchildyr tree_data[i]["lowestchildyr"] = node.lowestchildyr + if not node.is_root(): + tree_data[i]["arcx0"] = node.arcx0 + tree_data[i]["arcy0"] = node.arcy0 + tree_data[i]["arcstartangle"] = node.highest_child_clangle + tree_data[i]["arcendangle"] = node.lowest_child_clangle if node.name in names_to_keys: names_to_keys[node.name].append(i) @@ -252,8 +265,7 @@ def _to_dict(self): # This is used in biom-table. Currently this is only used to ignore # null data (i.e. NaN and "unknown") and also determines sorting order. # The original intent is to signal what columns are - # discrete/continuous. type of sample metadata (n - number, o - - # object) + # discrete/continuous. type of sample metadata (n - number, o - object) sample_data_type = self.samples.dtypes.to_dict() sample_data_type = {k: 'n' if pd.api.types.is_numeric_dtype(v) else 'o' for k, v in sample_data_type.items()} diff --git a/empress/support_files/js/empress.js b/empress/support_files/js/empress.js index cd3d095e7..259c99fe3 100644 --- a/empress/support_files/js/empress.js +++ b/empress/support_files/js/empress.js @@ -51,8 +51,11 @@ define([ * used to draw the tree * @private */ - this._drawer = new Drawer(canvas, this._cam); - this._canvas = canvas; + // allow canvas to be null to make testing empress easier + if (canvas !== null) { + this._drawer = new Drawer(canvas, this._cam); + this._canvas = canvas; + } /** * @type {Array} @@ -126,7 +129,10 @@ define([ * @type{CanvasEvents} * Handles user events */ - this._events = new CanvasEvents(this, this._drawer, canvas); + // allow canvas to be null to make testing empress easier + if (canvas !== null) { + this._events = new CanvasEvents(this, this._drawer, canvas); + } } /** @@ -175,12 +181,32 @@ define([ Empress.prototype.computeNecessaryCoordsSize = function () { var numLines; if (this._currentLayout === "Rectangular") { - // Leaves have 1 line (vertical), the root also has 1 line - // (horizontal), and internal nodes have 2 lines (both vertical and - // horizontal). + // Leaves have 1 line (horizontal), the root also has 1 line + // (vertical), and internal nodes have 2 lines (both vertical and + // horizontal). As an example, the below tiny tree shown in + // rectangular layout contains 5 nodes total (including the root) + // and 3 leaves. So numLines = 3 + 1 + 2*(5 - (3 + 1)) = 3+1+2 = 6. + // + // +-- + // +---| + // | +- + // | + // +-------- var leafAndRootCt = this._tree.numleafs() + 1; numLines = leafAndRootCt + 2 * (this._tree.size - leafAndRootCt); + } else if (this._currentLayout === "Circular") { + // All internal nodes (excpet root which is just a point) have an + // arc that is made out of 15 small line segments. In addition, all + // non-root nodes have an additional line that connects them to + // their parents arc. So the same example above would have + // numLines = 3 + 16 * (5 - 3 - 1) = 19. + // I.E. 3 lines for the leafs and 16*(5 - 3 - 1) lines for the + // internal nodes. The -1 is there because we do not draw a line for + // the root. + var leafCt = this._tree.numleafs(); + numLines = leafCt + 16 * (this._tree.size - leafCt - 1); } else { + // the root is not drawn numLines = this._tree.size - 1; } @@ -231,13 +257,13 @@ define([ var coords_index = 0; - /* Draw a vertical line for the root node, if we're in rectangular - * layout mode. Note that we *don't* draw a horizontal line for the - * root node, even if it has a nonzero branch length; this could be - * modified in the future if desired. See #141 on GitHub. + /* Draw a vertical line, if we're in rectangular layout mode. Note that + * we *don't* draw a horizontal line (with the branch length of the + * root) for the root node, even if it has a nonzero branch length; + * this could be modified in the future if desired. See #141 on GitHub. * * (The python code explicitly disallows trees with <= 1 nodes, so - * we're never going to be in the unforuntate situation of having the + * we're never going to be in the unfortunate situation of having the * root be the ONLY node in the tree. So this behavior is ok.) */ if (this._currentLayout === "Rectangular") { @@ -302,6 +328,64 @@ define([ coords.set(color, coords_index); coords_index += 3; } + } else if (this._currentLayout === "Circular") { + /* Same deal as above, except instead of a "vertical line" this + * time we draw an "arc". + */ + // 1. Draw line protruding from parent (we're already skipping + // the root so this is ok) + // + // Note that position info for this is stored as two sets of + // coordinates: (xc0, yc0) for start point, (xc1, yc1) for end + // point. The *c1 coordinates are explicitly associated with + // the circular layout so we can just use this.getX() / + // this.getY() for these coordinates. + coords[coords_index++] = this._treeData[node].xc0; + coords[coords_index++] = this._treeData[node].yc0; + coords.set(color, coords_index); + coords_index += 3; + coords[coords_index++] = this.getX(this._treeData[node]); + coords[coords_index++] = this.getY(this._treeData[node]); + coords.set(color, coords_index); + coords_index += 3; + // 2. Draw arc, if this is an internal node (note again that + // we're skipping the root) + if (this._treeData[node].hasOwnProperty("arcx0")) { + // arcs are create by sampling 15 small lines along the + // arc spanned by rotating (arcx0, arcy0), the line whose + // origin is the root of the tree and endpoint is the start + // of the arc, by arcendangle - arcstartangle radians. + var numSamples = 15; + var arcDeltaAngle = + this._treeData[node].arcendangle - + this._treeData[node].arcstartangle; + var sampleAngle = arcDeltaAngle / numSamples; + var sX = this._treeData[node].arcx0; + var sY = this._treeData[node].arcy0; + for (var line = 0; line < numSamples; line++) { + var x = + sX * Math.cos(line * sampleAngle) - + sY * Math.sin(line * sampleAngle); + var y = + sX * Math.sin(line * sampleAngle) + + sY * Math.cos(line * sampleAngle); + coords[coords_index++] = x; + coords[coords_index++] = y; + coords.set(color, coords_index); + coords_index += 3; + + 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); + coords[coords_index++] = x; + coords[coords_index++] = y; + coords.set(color, coords_index); + coords_index += 3; + } + } } else { // Draw nodes for the unrooted layout. // coordinate info for parent @@ -353,42 +437,34 @@ define([ * bL) being drawn. * * Note that this doesn't do any validation on the relative positions of - * the tL / tR / bL / bR coordinates, so if those are messed up (e.g. - * you're trying to draw the rectangle shown above but you accidentally - * swap bL and tL) then this will just draw something weird. + * the corners coordinates, so if those are messed up (e.g. you're trying + * to draw the rectangle shown above but you accidentally swap bL and tL) + * then this will just draw something weird. * * (Also note that we can modify coords because JS uses "Call by sharing" * for Arrays/Objects; see http://jasonjl.me/blog/2014/10/15/javascript.) * * @param {Array} coords Array containing coordinate + color data, to be * passed to Drawer.loadSampleThickBuf(). - * @param {Array} tL top-left position, represented as [x, y] - * @param {Array} tR top-right position, represented as [x, y] - * @param {Array} bL bottom-left position, represented as [x, y] - * @param {Array} bR bottom-right position, represented as [x, y] + * @param {Object} corners Object with tL, tR, bL, and bR entries (each + * mapping to an array of the format [x, y] + * indicating this position). * @param {Array} color the color to draw / fill both triangles with */ - Empress.prototype._addTriangleCoords = function ( - coords, - tL, - tR, - bL, - bR, - color - ) { + Empress.prototype._addTriangleCoords = function (coords, corners, color) { // Triangle 1 - coords.push(...tL); + coords.push(...corners.tL); coords.push(...color); - coords.push(...bL); + coords.push(...corners.bL); coords.push(...color); - coords.push(...bR); + coords.push(...corners.bR); coords.push(...color); // Triangle 2 - coords.push(...tL); + coords.push(...corners.tL); coords.push(...color); - coords.push(...tR); + coords.push(...corners.tR); coords.push(...color); - coords.push(...bR); + coords.push(...corners.bR); coords.push(...color); }; @@ -403,53 +479,63 @@ define([ * passed to Drawer.loadSampleThickBuf(). * @param {Number} node Node index in this._treeData, from which we'll * retrieve coordinate information. - * @param {Number} amount Desired line thickness (note that this will be - * applied on both sides of the line -- so if - * amount = 1 here then the drawn thick line will - * have a width of 1 + 1 = 2). + * @param {Number} level Desired line thickness (note that this will be + * applied on both sides of the line -- so if + * level = 1 here then the drawn thick line will + * have a width of 1 + 1 = 2). */ Empress.prototype._addThickVerticalLineCoords = function ( coords, node, - amount + level ) { - var tL = [ - this.getX(this._treeData[node]) - amount, - this._treeData[node].highestchildyr, - ]; - var tR = [ - this.getX(this._treeData[node]) + amount, - this._treeData[node].highestchildyr, - ]; - var bL = [ - this.getX(this._treeData[node]) - amount, - this._treeData[node].lowestchildyr, - ]; - var bR = [ - this.getX(this._treeData[node]) + amount, - this._treeData[node].lowestchildyr, - ]; + var corners = { + tL: [ + this.getX(this._treeData[node]) - level, + this._treeData[node].highestchildyr, + ], + tR: [ + this.getX(this._treeData[node]) + level, + this._treeData[node].highestchildyr, + ], + + bL: [ + this.getX(this._treeData[node]) - level, + this._treeData[node].lowestchildyr, + ], + + bR: [ + this.getX(this._treeData[node]) + level, + this._treeData[node].lowestchildyr, + ], + }; var color = this._treeData[node].color; - this._addTriangleCoords(coords, tL, tR, bL, bR, color); + this._addTriangleCoords(coords, corners, color); }; /** * Thickens the branches that belong to unique sample categories * (i.e. features that are only in gut) * - * @param {Number} amount - How thick to make branch + * @param {Number} level - Desired line thickness (note that this will be + * applied on both sides of the line -- so if + * level = 1 here then the drawn thick line will + * have a width of 1 + 1 = 2). */ - Empress.prototype.thickenSameSampleLines = function (amount) { + Empress.prototype.thickenSameSampleLines = function (level) { // we do this because SidePanel._updateSample() calls this function // with lWidth - 1, so in order to make sure we're setting this // properly we add 1 to this value. - this._currentLineWidth = amount + 1; + this._currentLineWidth = level + 1; var tree = this._tree; // the coordinate of the tree. var coords = []; this._drawer.loadSampleThickBuf([]); + // define theses variables so jslint does not complain + var x1, y1, x2, y2, corners; + // In the corner case where the root node (located at index tree.size) // has an assigned color, thicken the root's drawn vertical line when // drawing the tree in Rectangular layout mode @@ -457,9 +543,9 @@ define([ this._currentLayout === "Rectangular" && this._treeData[tree.size].sampleColored ) { - this._addThickVerticalLineCoords(coords, tree.size, amount); + this._addThickVerticalLineCoords(coords, tree.size, level); } - // iterate throught the tree in postorder, skip root + // iterate through the tree in postorder, skip root for (var i = 1; i < this._tree.size; i++) { // name of current node var node = i; @@ -470,11 +556,10 @@ define([ } var color = this._treeData[node].color; - var tL, tR, bL, bR; if (this._currentLayout === "Rectangular") { // Draw a thick vertical line for this node, if it isn't a tip if (this._treeData[node].hasOwnProperty("lowestchildyr")) { - this._addThickVerticalLineCoords(coords, node, amount); + this._addThickVerticalLineCoords(coords, node, level); } /* Draw a horizontal thick line for this node -- we can safely * do this for all nodes since this ignores the root, and all @@ -484,55 +569,87 @@ define([ * -----| * bL bR--- */ - tL = [ - this.getX(this._treeData[parent]), - this.getY(this._treeData[node]) + amount, - ]; - tR = [ - this.getX(this._treeData[node]), - this.getY(this._treeData[node]) + amount, - ]; - bL = [ - this.getX(this._treeData[parent]), - this.getY(this._treeData[node]) - amount, - ]; - bR = [ - this.getX(this._treeData[node]), - this.getY(this._treeData[node]) - amount, - ]; - this._addTriangleCoords(coords, tL, tR, bL, bR, color); + corners = { + tL: [ + this.getX(this._treeData[parent]), + this.getY(this._treeData[node]) + level, + ], + tR: [ + this.getX(this._treeData[node]), + this.getY(this._treeData[node]) + level, + ], + bL: [ + this.getX(this._treeData[parent]), + this.getY(this._treeData[node]) - level, + ], + bR: [ + this.getX(this._treeData[node]), + this.getY(this._treeData[node]) - level, + ], + }; + this._addTriangleCoords(coords, corners, color); + } else if (this._currentLayout === "Circular") { + // Thicken the "arc" if this is non-root internal node + // (TODO: this will need to be adapted when the arc is changed + // to be a bezier curve) + if (this._treeData[node].hasOwnProperty("arcx0")) { + // arcs are create by sampling 15 small lines along the + // arc spanned by rotating arcx0, the line whose origin + // is the root of the tree and endpoint is the start of the + // arc, by arcendangle - arcstartangle radians. + var numSamples = 15; + var arcDeltaAngle = + this._treeData[node].arcendangle - + this._treeData[node].arcstartangle; + var sampleAngle = arcDeltaAngle / numSamples; + var sX = this._treeData[node].arcx0; + var sY = this._treeData[node].arcy0; + for (var line = 0; line < numSamples; line++) { + x1 = + sX * Math.cos(line * sampleAngle) - + sY * Math.sin(line * sampleAngle); + y1 = + sX * Math.sin(line * sampleAngle) + + sY * Math.cos(line * sampleAngle); + x2 = + sX * Math.cos((line + 1) * sampleAngle) - + sY * Math.sin((line + 1) * sampleAngle); + y2 = + sX * Math.sin((line + 1) * sampleAngle) + + sY * Math.cos((line + 1) * sampleAngle); + var arc0corners = VectorOps.computeBoxCorners( + x1, + y1, + x2, + y2, + level + ); + var arc1corners = VectorOps.computeBoxCorners( + x1, + y1, + x2, + y2, + level + ); + this._addTriangleCoords(coords, arc0corners, color); + this._addTriangleCoords(coords, arc1corners, color); + } + } + // Thicken the actual "node" portion, extending from the center + // of the layout + x1 = this._treeData[node].xc0; + y1 = this._treeData[node].yc0; + x2 = this.getX(this._treeData[node]); + y2 = this.getY(this._treeData[node]); + corners = VectorOps.computeBoxCorners(x1, y1, x2, y2, level); + this._addTriangleCoords(coords, corners, color); } else { - // center branch such that parent node is at (0,0) - var x1 = this.getX(this._treeData[parent]); - var y1 = this.getY(this._treeData[parent]); - var x2 = this.getX(this._treeData[node]); - var y2 = this.getY(this._treeData[node]); - var point = VectorOps.translate([x1, y1], -1 * x2, -1 * y2); - - // find angle/length of branch - var angle = VectorOps.getAngle(point); - var length = VectorOps.magnitude(point); - var over = point[1] < 0; - - // find top left of box of thick line - tL = [0, amount]; - tL = VectorOps.rotate(tL, angle, over); - tL = VectorOps.translate(tL, x2, y2); - - tR = [length, amount]; - tR = VectorOps.rotate(tR, angle, over); - tR = VectorOps.translate(tR, x2, y2); - - // find bottom point of thick line - bL = [0, -1 * amount]; - bL = VectorOps.rotate(bL, angle, over); - bL = VectorOps.translate(bL, x2, y2); - - bR = [length, -1 * amount]; - bR = VectorOps.rotate(bR, angle, over); - bR = VectorOps.translate(bR, x2, y2); - - this._addTriangleCoords(coords, tL, tR, bL, bR, color); + x1 = this.getX(this._treeData[parent]); + y1 = this.getY(this._treeData[parent]); + x2 = this.getX(this._treeData[node]); + y2 = this.getY(this._treeData[node]); + corners = VectorOps.computeBoxCorners(x1, y1, x2, y2, level); + this._addTriangleCoords(coords, corners, color); } } diff --git a/empress/support_files/js/vector-ops.js b/empress/support_files/js/vector-ops.js index 0521c716c..8cf7616a9 100644 --- a/empress/support_files/js/vector-ops.js +++ b/empress/support_files/js/vector-ops.js @@ -1,7 +1,7 @@ /** @module vector utility-functions */ define([], function () { /** - * Finds the angle of vector w.r.t the x-axis + * Finds the cos and sin of the angle of vector w.r.t the x-axis * * @param {Array} point - the point to find the angle for * @@ -11,7 +11,11 @@ define([], function () { var x = point[0], y = point[1]; var cos = x / Math.sqrt(x * x + y * y); + + // Note: This will always result in the abs(sin). Thus, if the y + // component is negative then we have to multiple sin by -1 var sin = Math.sqrt(1 - cos * cos); + if (y < 0) sin = -1 * sin; return { cos: cos, @@ -36,28 +40,23 @@ define([], function () { * Rotates the vector * * @param {Array} point (x, y) coordinates - * @param {Number} angle The amount to rotate the vector + * @param {Object} angle The amount to rotate the vector + * if theta is the rotating amount, then angle + * is defined as {"cos": cos(theta), + "sin": sin(theta)} * @param {Boolean} over if true rotate point in positve sine direction * if false rotate point in negative sine direction * * @return {Array} */ - function rotate(point, angle, over) { + function rotate(point, angle) { var cos = angle.cos; var sin = angle.sin; var x = point[0]; var y = point[1]; - // rotate the point in the negative sine direction (i.e. beneath x axis) - if (over) { - sin = -1 * sin; - } - - x = point[0]; - y = point[1]; - - point[0] = cos * x + -1 * sin * y; - point[1] = sin * x + cos * y; + point[0] = x * cos + -1 * y * sin; + point[1] = x * sin + y * cos; return point; } @@ -76,10 +75,64 @@ define([], function () { return point; } + /** + * Returns an Object describing the top-left, top-right, bottom-left, and + * bottom-right coordinates of a "thick line" box connecting two points + * specified by (x1, y1) and (x2, y2). + * + * The output object from this function is directly passable into + * Empress._addTriangleCoords() as the corners parameter, for reference. + * + * @param {Number} x1 + * @param {Number} y1 + * @param {Number} x2 + * @param {Number} y2 + * @param {Number} amount - Thickness of the box to be drawn + * + * @return {Object} corners - Contains keys tL, tR, bL, bR + */ + function computeBoxCorners(x1, y1, x2, y2, amount) { + // // center line so that it starts at (0,0) + // var mag1 = magnitude([x1, y1), + // mag2 = magnitude([x2,y2]), + // point; + // if (mag1 > mag2) { + // point = translate([]) + // } + var point = translate([x2, y2], -1 * x1, -1 * y1); + + // find angle/length of branch + var angle = getAngle(point); + var length = magnitude(point); + + // find top left of box + bL = [0, amount]; + bL = rotate(bL, angle); + bL = translate(bL, x1, y1); + + // find top right of box + bR = [0, -1 * amount]; + bR = rotate(bR, angle); + bR = translate(bR, x1, y1); + + // find bottom left of box + tL = [length, amount]; + tL = rotate(tL, angle); + tL = translate(tL, x1, y1); + + //f find bottom right of box + tR = [length, -1 * amount]; + tR = rotate(tR, angle); + tR = translate(tR, x1, y1); + + return { tL: tL, tR: tR, bL: bL, bR: bR }; + } + return { getAngle: getAngle, magnitude: magnitude, rotate: rotate, translate: translate, + computeBoxCorners, }; }); diff --git a/empress/tree.py b/empress/tree.py index 7b8db85ac..ce023587c 100644 --- a/empress/tree.py +++ b/empress/tree.py @@ -125,9 +125,9 @@ def coords(self, height, width): coordinates for each node, so that layout algorithms can be rapidly toggled between in the JS interface. - Also adds on .highestchildyr and .lowestchildyr attributes to internal - nodes so that vertical bars for these nodes can be drawn in the - rectangular layout. + Also adds on .highestchildyr and .lowestchildyr attributes to + internal nodes so that vertical bars for these nodes can be drawn in + the rectangular layout. Parameters ---------- @@ -148,6 +148,7 @@ def coords(self, height, width): layout_algs = ( self.layout_unrooted, self.layout_rectangular, + self.layout_circular, ) # We set the default layout to whatever the first layout in # layout_algs is, but this behavior is of course modifiable @@ -156,6 +157,8 @@ def coords(self, height, width): name, suffix = alg(width, height) layout_to_coordsuffix[name] = suffix self.alter_coordinates_relative_to_root(suffix) + if name == "Circular": + self.alter_coordinates_relative_to_root("c0") if default_layout is None: default_layout = name @@ -163,16 +166,20 @@ def coords(self, height, width): # the rectangular layout; used to draw vertical lines for these nodes. # # NOTE / TODO: This will have the effect of drawing vertical lines even - # for nodes with only 1 child -- in this case lowestchildyr == - # highestchildyr for this node, so all of the stuff drawn in WebGL for - # this vertical line shouldn't show up. I don't think this should cause - # any problems, but it may be worth detecting these cases and not + # for nodes with only 1 child -- in this case lowest_childyr == + # highestchildyr for this node, so all of the stuff drawn in WebGL + # for this vertical line shouldn't show up. I don't think this should + # cause any problems, but it may be worth detecting these cases and not # drawing vertical lines for them in the future. for n in self.preorder(): if not n.is_tip(): - child_y_coords = [c.yr for c in n.children] - n.highestchildyr = max(child_y_coords) - n.lowestchildyr = min(child_y_coords) + n.highestchildyr = float("-inf") + n.lowestchildyr = float("inf") + for c in n.children: + if c.yr > n.highestchildyr: + n.highestchildyr = c.yr + if c.yr < n.lowestchildyr: + n.lowestchildyr = c.yr return layout_to_coordsuffix, default_layout @@ -215,7 +222,7 @@ def layout_rectangular(self, width, height): y-positions are centered over their descendant tips' positions. x-positions are computed based on nodes' branch lengths. - Following this algorithm, nodes' unrooted layout coordinates are + Following this algorithm, nodes' rectangular layout coordinates are accessible at [node].xr and [node].yr. For a simple tree, this layout should look something like: @@ -295,6 +302,154 @@ def layout_rectangular(self, width, height): # horizontal line is proportional to the node length in question). return "Rectangular", "r" + def layout_circular(self, width, height): + """ Circular layout version of the rectangular layout. + + Works analogously to the rectangular layout: + + -Each tip is assigned a unique angle from the "center"/root of + the tree (out of the range [0, 2pi] in radians), and internal + nodes are set to an angle equal to the average of their + children's. This mirrors the assignment of y-coordinates for + the rectangular layout. + + -All nodes are then assigned a radius equal to the sum of their + branch lengths descending from the root (but not including + the root's branch length, if provided -- the root is represented + as just a single point in the center of the layout). This mirrors + the assignment of x-coordinates for the rectangular layout. + + -Lastly, we'll draw arcs for every internal node (except for the + root) connecting the "start points" of the child nodes of that + node with the minimum and maximum angle. (These points should + occur at the radius equal to the "end" of the given internal + node.) + We don't draw this arc for the root node because we don't draw + the root the same way we do the other nodes in the tree: + the root is represented as just a single point at the center + of the layout. Due to this, there isn't a way to draw an arc + from the root, since the root's "end" is at the same point as + its beginning (so the arc wouldn't be visible). + + Following this algorithm, nodes' circular layout coordinates are + accessible at [node].xc and [node].yc. Angles will also be available + at [node].clangle, and radii will be available at [node].clradius; and + for non-root internal nodes, arc start and end coordinates will be + available at [node].arcx0, [node].arcy0, [node].arcx1, & [node].arcy1. + + Parameters + ---------- + width : float + width of the canvas + height : float + height of the canvas + + References + ---------- + https://github.com/qiime/Topiary-Explorer/blob/master/src/topiaryexplorer/TreeVis.java + Description above + the implementation of this algorithm + derived from the Polar layout algorithm code. + """ + anglepernode = (2 * np.pi) / self.leafcount + prev_clangle = 0 + for n in self.postorder(): + if n.is_tip(): + n.clangle = prev_clangle + prev_clangle += anglepernode + else: + # Center internal nodes at an angle above their children + n.clangle = sum([c.clangle for c in n.children])\ + / len(n.children) + + max_clradius = 0 + self.clradius = 0 + for n in self.preorder(include_self=False): + n.clradius = n.parent.clradius + n.length + if n.clradius > max_clradius: + max_clradius = n.clradius + + # Now that we have the polar coordinates of the nodes, convert these + # coordinates to normal x/y coordinates. + # NOTE that non-root nodes will actually have two x/y coordinates we + # need to keep track of: one for the "end" of the node's line, and + # another for the "start" of the node's line. The latter of these is + # needed because the node's line begins at the parent node's radius but + # the child node's angle, if that makes sense -- and since converting + # from polar to x/y and back is annoying, it's easiest to just compute + # this in python. + max_x = max_y = float("-inf") + min_x = min_y = float("inf") + for n in self.postorder(): + n.xc1 = n.clradius * np.cos(n.clangle) + n.yc1 = n.clradius * np.sin(n.clangle) + if n.is_root(): + # NOTE that the root has a clradius of 0 (since it's just + # represented as a point at the center of the layout). We don't + # even bother drawing the root in the Empress JS code, but for + # the purposes of alter_coordinates_relative_to_root() we need + # to explicitly position the root at (0, 0). + n.xc0 = 0 + n.yc0 = 0 + else: + n.xc0 = n.parent.clradius * np.cos(n.clangle) + n.yc0 = n.parent.clradius * np.sin(n.clangle) + # NOTE: We don't bother testing the xc0 / yc0 coordinates as + # "extrema" because they should always be further "within" the + # tree than the xc1 / yc1 coordinates. + # TODO: verify that the "tree is a line" case doesn't mess this up. + if n.xc1 > max_x: + max_x = n.xc1 + if n.yc1 > max_y: + max_y = n.yc1 + if n.xc1 < min_x: + min_x = n.xc1 + if n.yc1 < min_y: + min_y = n.yc1 + + # TODO: raise error if the maximum and minimum are same for x or y. + # may happen if the tree is a straight line. + + # set scaling factors + # normalize the coordinate based on the largest dimension + width_scale = width / (max_x - min_x) + height_scale = height / (max_y - min_y) + scale_factor = width_scale if width_scale > height_scale else\ + height_scale + x_scaling_factor = scale_factor + y_scaling_factor = scale_factor + + for n in self.preorder(): + n.xc0 *= x_scaling_factor + n.yc0 *= y_scaling_factor + n.xc1 *= x_scaling_factor + n.yc1 *= y_scaling_factor + if not n.is_tip() and not n.is_root(): + n.highest_child_clangle = float("-inf") + n.lowest_child_clangle = float("inf") + for c in n.children: + if c.clangle > n.highest_child_clangle: + n.highest_child_clangle = c.clangle + if c.clangle < n.lowest_child_clangle: + n.lowest_child_clangle = c.clangle + # Figure out "arc" endpoints for the circular layout + # NOTE: As with the "vertical lines" for internal nodes in the + # rectangular layout, these arcs will be drawn for nodes with + # only one child. Here, this case would mean that the + # highest_child_clangle would equal the lowest_child_clangle, + # so arcx0 would equal arcx1 and arcy0 would equal arcy1. So + # nothing should show up (but it may be worth addressing this + # in the future). + n.arcx0 = n.clradius * np.cos(n.highest_child_clangle) + n.arcy0 = n.clradius * np.sin(n.highest_child_clangle) + n.arcx1 = n.clradius * np.cos(n.lowest_child_clangle) + n.arcy1 = n.clradius * np.sin(n.lowest_child_clangle) + n.arcx0 *= x_scaling_factor + n.arcy0 *= y_scaling_factor + n.arcx1 *= x_scaling_factor + n.arcy1 *= y_scaling_factor + + return "Circular", "c1" + def layout_unrooted(self, width, height): """ Find best scaling factor for fitting the tree in the figure. This method will find the best orientation and scaling possible to diff --git a/tests/index.html b/tests/index.html index c3328ac2c..c5e68ec6e 100644 --- a/tests/index.html +++ b/tests/index.html @@ -40,7 +40,12 @@ 'Colorer' : './support_files/js/colorer', 'BiomTable': './support_files/js/biom-table', 'SummaryHelper': './support_files/js/summary-helper', - 'util': './support_files/js/util', + 'util' : './support_files/js/util', + 'Empress' : './support_files/js/empress', + 'Drawer' : './support_files/js/drawer', + 'VectorOps' : './support_files/js/vector-ops', + 'CanvasEvents' : './support_files/js/canvas-events', + 'SelectedNodeMenu' : './support_files/js/select-node-menu', /* test paths */ 'testBPTree' : './../tests/test-bp-tree', @@ -49,21 +54,25 @@ 'testBIOMTable' : './../tests/test-biom-table', 'testColorer' : './../tests/test-colorer', 'testSummaryHelper': './../tests/test-summary-helper', - 'testUtil': './../tests/test-util' + 'testUtil' : './../tests/test-util', + 'testCircularLayoutComputation' : './../tests/test-circular-layout-computation', + 'testVectorOps' : './../tests/test-vector-ops' } }); // load tests require( ['jquery', 'glMatrix', 'chroma', 'underscore', 'ByteArray', 'BPTree', - 'Camera', 'Colorer', 'BiomTable', 'SummaryHelper', 'util', + 'Camera', 'Colorer', 'BiomTable', 'SummaryHelper', 'util', 'Empress', 'testBPTree', 'testByteTree', 'testBIOMTable', 'testCamera', - 'testColorer', 'testSummaryHelper', 'testUtil'], + 'testColorer', 'testSummaryHelper', 'testUtil', + 'testCircularLayoutComputation', 'testVectorOps'], // start tests function ($, gl, chroma, underscore, ByteArray, BPTree, Camera, - testBIOMTable, Colorer, BiomTable, SummaryHelper, util, testBPTree, - testByteTree, testCamera, testColorer, testSummaryHelper, testUtil) { + testBIOMTable, Colorer, BiomTable, SummaryHelper, util, Empress, + testBPTree, testByteTree, testCamera, testColorer, testSummaryHelper, + testUtil, testCircularLayoutComputation, testVectorOps) { $(document).ready(function() { QUnit.start(); }); diff --git a/tests/python/test_core.py b/tests/python/test_core.py index ae52c22ed..fbaf64de7 100644 --- a/tests/python/test_core.py +++ b/tests/python/test_core.py @@ -79,6 +79,7 @@ def setUp(self): proportion_explained=proportion_explained) self.files_to_remove = [] + self.maxDiff = None def tearDown(self): for path in self.files_to_remove: @@ -249,7 +250,9 @@ def test_filter_unobserved_features_from_phylogeny(self): 'emperor_div': '', 'emperor_require_logic': '', 'emperor_style': '', - 'layout_to_coordsuffix': {'Rectangular': 'r', 'Unrooted': '2'}, + 'layout_to_coordsuffix': {'Circular': 'c1', + 'Rectangular': 'r', + 'Unrooted': '2'}, 'names': ['EmpressNode2', 'g', 'EmpressNode0', @@ -299,8 +302,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': -82.19088834200284, + 'xc0': 1481.4675640601124, + 'xc1': 2222.2013460901685, 'xr': 2412.0, 'y2': 1568.2955749395592, + 'yc0': 0.0, + 'yc1': 0.0, 'yr': -2386.875}, 2: {'color': [0.75, 0.75, 0.75], 'name': 'e', @@ -308,10 +315,18 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': 948.7236134182863, + 'xc0': 457.7986539098309, + 'xc1': 915.5973078196618, 'xr': 3216.0, 'y2': 2108.2845722271436, + 'yc0': 1408.9593804792778, + 'yc1': 2817.9187609585556, 'yr': -1381.875}, - 3: {'color': [0.75, 0.75, 0.75], + 3: {'arcendangle': 0, + 'arcstartangle': 1.2566370614359172, + 'arcx0': 457.7986539098309, + 'arcy0': 1408.9593804792778, + 'color': [0.75, 0.75, 0.75], 'highestchildyr': -1381.875, 'lowestchildyr': -2386.875, 'name': 'EmpressNode0', @@ -319,8 +334,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': 295.3117872853636, + 'xc0': 599.2662179699436, + 'xc1': 1198.5324359398871, 'xr': 1608.0, 'y2': 1102.1185942229504, + 'yc0': 435.3923929520944, + 'yc1': 870.7847859041888, 'yr': -1884.375}, 4: {'color': [0.75, 0.75, 0.75], 'name': 'b', @@ -328,10 +347,18 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': 1485.5419815224768, + 'xc0': -599.2662179699435, + 'xc1': -1797.7986539098304, 'xr': 2412.0, 'y2': 192.57380029925002, + 'yc0': 435.3923929520945, + 'yc1': 1306.1771788562833, 'yr': -376.875}, - 5: {'color': [0.75, 0.75, 0.75], + 5: {'arcendangle': 0.6283185307179586, + 'arcstartangle': 2.5132741228718345, + 'arcx0': -599.2662179699435, + 'arcy0': 435.3923929520945, + 'color': [0.75, 0.75, 0.75], 'highestchildyr': -376.875, 'lowestchildyr': -1884.375, 'name': 'g', @@ -339,8 +366,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': 326.7059130664611, + 'xc0': 0.0, + 'xc1': 4.5356862759171076e-14, 'xr': 804.0, 'y2': 503.08298900209684, + 'yc0': 0.0, + 'yc1': 740.7337820300562, 'yr': -1130.625}, 6: {'color': [0.75, 0.75, 0.75], 'name': 'EmpressNode1', @@ -348,8 +379,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': -622.0177003518252, + 'xc0': -1198.5324359398871, + 'xc1': -1797.7986539098308, 'xr': 2412.0, 'y2': -1605.201583225047, + 'yc0': -870.7847859041887, + 'yc1': -1306.177178856283, 'yr': 628.125}, 7: {'color': [0.75, 0.75, 0.75], 'name': 'd', @@ -357,10 +392,18 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': -2333.458018477523, + 'xc0': 457.79865390983053, + 'xc1': 1144.4966347745763, 'xr': 4020.0, 'y2': -1651.0752884434787, + 'yc0': -1408.9593804792778, + 'yc1': -3522.398451198195, 'yr': 1633.125}, - 8: {'color': [0.75, 0.75, 0.75], + 8: {'arcendangle': 3.7699111843077517, + 'arcstartangle': 5.026548245743669, + 'arcx0': 457.79865390983053, + 'arcy0': -1408.9593804792778, + 'color': [0.75, 0.75, 0.75], 'highestchildyr': 1633.125, 'lowestchildyr': 628.125, 'name': 'h', @@ -368,8 +411,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': -653.4118261329227, + 'xc0': -0.0, + 'xc1': -457.79865390983105, 'xr': 1608.0, 'y2': -1006.1659780041933, + 'yc0': -0.0, + 'yc1': -1408.9593804792778, 'yr': 1130.625}, 9: {'color': [0.75, 0.75, 0.75], 'highestchildyr': 1130.625, @@ -379,8 +426,12 @@ def test_filter_unobserved_features_from_phylogeny(self): 'single_samp': False, 'visible': True, 'x2': 0.0, + 'xc0': 0.0, + 'xc1': 0.0, 'xr': 0.0, 'y2': 0.0, + 'yc0': 0.0, + 'yc1': 0.0, 'yr': 0.0}}} diff --git a/tests/python/test_tree.py b/tests/python/test_tree.py index 4b7e6309a..c5ffc89a4 100644 --- a/tests/python/test_tree.py +++ b/tests/python/test_tree.py @@ -7,6 +7,7 @@ import unittest from skbio import TreeNode from empress import Tree +from math import sqrt class TestTree(unittest.TestCase): @@ -221,6 +222,77 @@ def test_missing_root_length_tree_rect_layout(self): self.assertEqual(node.highestchildyr, 0) self.check_basic_tree_rect_layout(t) + def test_circular_layout_scaling_factor(self): + """Checks to make sure the scaling factor applied at the end of + the circular layout calculation preservers branch lengths. Basically + a nodes length in the circular layout space should be proportional + to its branch length. + """ + st = TreeNode.read(["((d:4,c:3)b:2,a:1)root:1;"]) + t = Tree.from_tree(st) + t.coords(100, 100) + + # All nodes' length (beside the root which is represented by a point) + # in the circular layout space should have roughly the + # same proportional length compared to their branch length. + # + # For example, in the above tree, if d's length in the circular layout + # space is 1.5x larger than its branch length than all nodes should be + # roughly 1.5x larger than their branch lengths. + test_prop = None + for n in t.preorder(include_self=False): + n_prop = sqrt((n.xc1-n.xc0)**2 + (n.yc1-n.yc0)**2) / n.length + if test_prop is None: + test_prop = n_prop + else: + self.assertAlmostEqual(test_prop, n_prop, places=5) + + def test_circular_layout(self): + """Test to make sure the circular layout computes what we expect it to. + For each node, circular layou computer the following things: + (xc0, yc0) - the starting location for each node + (xc1, yc1) - the ending location for each node + + Then, all non-root internal nodes, have an arc that connects the + "starting points" of the children with the minimum and maximum + angle: + (arcx0, arcy0) - the starting location for the arc + highest_child_clangle - the starting angle for the arc + lowest_child_clangle - the ending angle for the arc + """ + st = TreeNode.read(["((d:4,c:3)b:2,a:1)root:1;"]) + t = Tree.from_tree(st) + t.coords(100, 100) + + # check starting location for each node + # Note: nodes 'a' and 'b' should have the same starting coordinates + # since they both start at the root. + expected_start = [(38.490018, 0.0), + (-19.245009, 33.333333), + (0.0, 0.0), + (0.0, 0.0), + (0.0, 0.0)] + self.check_coords(t, "xc0", "yc0", expected_start) + + # check ending location for each node + expected_end = [(115.470054, 0.0), + (-48.112522, 83.333333), + (19.245009, 33.333333), + (-9.622504, -16.666667), + (0.0, 0.0)] + self.check_coords(t, "xc1", "yc1", expected_end) + + # check starting location for b's arc + expected_arc = [-19.245009, 33.333333] + b = t.find("b") + self.assertAlmostEqual(b.arcx0, expected_arc[0], places=5) + self.assertAlmostEqual(b.arcy0, expected_arc[1], places=5) + + # check b's arc angles + expected_angles = [2.0943951, 0.0] + self.assertAlmostEqual(b.highest_child_clangle, expected_angles[0]) + self.assertAlmostEqual(b.lowest_child_clangle, expected_angles[1]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test-circular-layout-computation.js b/tests/test-circular-layout-computation.js new file mode 100644 index 000000000..0e857372d --- /dev/null +++ b/tests/test-circular-layout-computation.js @@ -0,0 +1,96 @@ +require(['jquery', 'BPTree', 'Empress'], function($, BPTree, Empress) { + $(document).ready(function() { + // Setup test variables + // Note: This is ran for each test() so tests can modify bpArray without + // effecting other test + module('Circular Layout Computation' , { + setup: function() { + var tree = new BPTree( + new Uint8Array([1, 1, 1, 0, 1, 0, 0, 1, 0, 0])); + var layoutToCoordSuffix = {"Circular": "c1"}; + var treeData = { + 1: { + "color":[1.0, 1.0, 1.0], + "xc0": -2, + "yc0": 2, + "xc1": -2, + "yc1": 0, + "visible": true + }, + 2: { + "color":[1.0, 1.0, 1.0], + "xc0": 2, + "yc0": 2, + "xc1": 2, + "yc1": 0, + "visible": true + }, + 3: { + "color":[1.0, 1.0, 1.0], + "xc0": 0, + "yc0": 1, + "xc1": 0, + "yc1": -1, + "arcx0": 2, + "arcy0": 0, + "arcstartangle": 0, + "arcendangle": Math.PI, + "visible": true + }, + 4: { + "color":[1.0, 1.0, 1.0], + "xc0": 0, + "yc0": -3, + "xc1": 0, + "yc1": -1, + "visible": true + }, + 5: { + "color":[1.0, 1.0, 1.0], + "xc0": 0, + "yc0": -1, + "xc1": 0, + "yc1": -1, + "visible": true + } + + } + this.empress = new Empress(tree, treeData, null, + layoutToCoordSuffix, "Circular", null, null); + this.empress._drawer = new Object(); + this.empress._drawer.VERTEX_SIZE = 5; + }, + + teardown: function() { + this.empress = null; + } + }); + + test('Test Circular Layout Arc Computation', function() { + var coords = this.empress.getCoords(); + + // NOTE: all node numbers are in reference to the postorder position + // starting at 1. + // check if line for node 1 is correct (tip) + var node = 1 + equal(coords[(node-1)*10], -2); // start x position + equal(coords[(node-1)*10 + 1], 2); // start y position + equal(coords[(node-1)*10 + 5], -2); // end x position + equal(coords[(node-1)*10 + 6], 0); // end y position + + // check if line for node 3 is correct (internal) + node = 3; + equal(coords[(node-1)*10], 0); // start x position + equal(coords[(node-1)*10 + 1], 1); // start y position + equal(coords[(node-1)*10 + 5], 0); // end x position + equal(coords[(node-1)*10 + 6], -1); // end y position + + // For the arc for node 3 start at (2,0) and ends at (-2, 0) + // check if arc for node 3 is correct + ok(Math.abs(coords[30] - 2) < 1.0e-15); // start x arc position + ok(Math.abs(coords[31 - 0]) < 1.0e-15); //start y arc position + ok(Math.abs(coords[175] - -2) < 1.0e-15) // end x arc position + ok(Math.abs(coords[176] - 0 < 1.0e-15)) // end y arc position + }); + }); +}); diff --git a/tests/test-vector-ops.js b/tests/test-vector-ops.js new file mode 100644 index 000000000..1d3451e56 --- /dev/null +++ b/tests/test-vector-ops.js @@ -0,0 +1,93 @@ +require(["jquery", "VectorOps"], function($, VectorOps) { + $(document).ready(function() { + // Setup test variables + module("VectorOps" , { + setup: function() { + }, + + teardown: function() { + } + }); + + // tests the constructor of bp tree + test("Test getAngle", function() { + // point on the positive x-axis + deepEqual(VectorOps.getAngle([1,0]), {"cos" : 1, "sin" : 0}); + deepEqual(VectorOps.getAngle([100,0]), {"cos" : 1, "sin" : 0}); + + //point on the negative x-axis + deepEqual(VectorOps.getAngle([-1,0]), {"cos" : -1, "sin" : 0}); + + // point on the positive y-axis + deepEqual(VectorOps.getAngle([0,1]), {"cos" : 0, "sin" : 1}); + + // point on the negative y-axis + deepEqual(VectorOps.getAngle([0,-1]), {"cos" : 0, "sin" : -1}); + + // arbitrary point + var angle = VectorOps.getAngle([1, 1]); + ok(Math.abs(angle.cos - Math.sqrt(2) / 2 < 1.0e-15)); + ok(Math.abs(angle.sin - Math.sqrt(2) / 2 < 1.0e-15)); + + // arbitrary point + angle = VectorOps.getAngle([-5*Math.sqrt(3) / 2, -5 / 2]); + ok(Math.abs(angle.cos - (-1*Math.sqrt(3))/2) < 1.0e-15); + ok(Math.abs(angle.sin - (-1/2)) < 1.0e-15); + }); + + test("Test magnitude", function() { + equal(VectorOps.magnitude([0,0]), 0); + equal(VectorOps.magnitude([1,1]), Math.sqrt(2)); + equal(VectorOps.magnitude([1,-1]), Math.sqrt(2)); + equal(VectorOps.magnitude([-5,-2]), Math.sqrt(29)); + }); + + test("Test rotate", function() { + // rotate [0,1] by 0 or 360 degrees + deepEqual( + VectorOps.rotate([0,1], + {"cos" : 1, "sin" : 0}), [0,1]); + + // rotate [-1, -1] by 90 degress + deepEqual( + VectorOps.rotate([-1,-1], + {"cos" : 0, "sin" : 1}), [1,-1]); + + // rotate [1,1] by 240 degrees + var angle = { + "cos" : (-1/2), + "sin" : (-1*Math.sqrt(3)/2) + } + var rPoint = VectorOps.rotate([1,1],angle); + ok(Math.abs(rPoint[0]- 0.3660254037844386) < 1.0e-15); + ok(Math.abs(rPoint[1] - -1.3660254037844386) < 1.0e-15); + }); + + test("Test translate", function() { + deepEqual(VectorOps.translate([0, 0], 1, 2), [1,2]); + deepEqual(VectorOps.translate([1, 2], -1, -2), [0,0]); + }); + + test("Test computeBoxCorners", function() { + var box = VectorOps.computeBoxCorners(-2, 0, 3, 0, 2); + deepEqual( + box, + { + "bL" : [-2, 2], + "bR" : [-2, -2], + "tL" : [3, 2], + "tR" : [3, -2] + }); + + box = VectorOps.computeBoxCorners(0,-2, 0, 3, 2); + deepEqual( + box, + { + "bL" : [-2,-2], + "bR" : [2, -2], + "tL" : [-2, 3], + "tR" : [2, 3] + }); + }); + }); +});