Skip to content

Commit

Permalink
Circular Layout (#180)
Browse files Browse the repository at this point in the history
* node-hover

* fixed table/added tests

* fixed table/added tests

* removed comment

* Update empress/support_files/js/canvas-events.js

Co-authored-by: Marcus Fedarko <[email protected]>

* Update empress/support_files/js/canvas-events.js

Co-authored-by: Marcus Fedarko <[email protected]>

* Update empress/support_files/js/biom-table.js

Co-authored-by: Marcus Fedarko <[email protected]>

* Update empress/support_files/js/biom-table.js

Co-authored-by: Marcus Fedarko <[email protected]>

* ENH: Add initial stab at circular layout

Needs further inspection and testing, and of course the JS
needs to be modified to draw "vertical lines" as for the rect. layout
(but now with curves, etc.) but this seems like a decent start

* DOC: add details re circ layout alg and TODOs

* ENH: draw circ layout arcs (not beziers yet), etc

Lotta ugly hacky code in this commit. Will get things working
then from there backtrack to make things pretty and well tested before
the PR.

This is looking actually pretty nice. Will probably need to fix the
"shape" of the circular layout though LOL

* Update empress/support_files/js/drawer.js

Co-authored-by: Yoshiki Vázquez Baeza <[email protected]>

* Update tests/test-biom-table.js

Co-authored-by: Yoshiki Vázquez Baeza <[email protected]>

* MNT: don't generate arc info for root (circlayout)

* BUG: still store pos data for root

* MNT: on 2nd thought draw "arc" for c.layout root

consistency will be useful, and we can adjust later as needed

also improved js docs for # lines needed

* DOC: document c0/c1 stuff for circ layout better

* BUG: Actually draw tips for circ layout

turns out that the reason things in this layout looked 'incomplete',
off, etc. was that xc0 and yc0 info was only being stored for internal
nodes, giving the dubious impression that penultimate nodes were tips.

With this, remaining circular layout TODOs are

1) drawing fancy curved arcs for internal nodes
2) adjust the UI to get the thick line stuff to work properly with
   the circular layout
3) clean up code, esp messy portions (like maybe store xc0/xc1 info
   in the layoutToCoordSuffix object somehow, by making it interpret
   an array of two suffixes as start and endpoints)
3) add tests

* MNT: Don't store root arc info; smooth arcs; docs

Improved documentation on *why* attempting to draw an arc
for the root is kinda useless. Hopefully shouldn't have to update
that again ._.

I split up the line segment for the circular layout into two, one
connecting an internal node's endpoint with its arc start and another
connecting the node endpoint with its arc endpoint. This makes the
layout look A LOT better; it's still essentially an approximation,
though. What we need to do is hijack the WebGL code to draw bezier
curves (starting and ending at the arc start/endpoints, and passing
through the internal node endpoint) instead. Uh, hopefully that isn't
too difficult? LOL I guess we'll see.

* DOC: improve circ layout coordinate docs

Addressing some ambiguities, and fixing an error w prev commit docs

* DOC: add note re: internal node arcs + cornercases

* BUG/MNT: Proper thick lines in c.layout; code chgs

I moved the general "corner-computing" code from within
Empress.thickenSameSampleLines() to its own function within VectorOps.

This allows a lot more code reuse, but I think we could make the code
even more compact by allowing _addTriangleCoords() to accept
{tL: ..., ...} objects directly.

* MNT: Make _addTriangleCoords accept corners obj

... Instead of accepting individual corner coordinates. This makes
life a bit easier, or at least makes the code a bit cleaner (since
now we can funnel the output from VectorOps.computeBoxCorners()
straight into Empress._addTriangleCoords()).

Of course it's worth noting that making the circular layout draw
the arcs with curved lines/beziers (and not just as the disjointed
line segments) will probably mean we'll have to abandon this
particular function for that particular use case. for now, tho, this
looks pretty nice

* added autocomplete, and handled multiple nodes found

* ENH: Small readability changes for hover table

* named autocomp div

* linted code

* for reference, update qzv

* circular layout

* added tests to circular layout calculation

* fix lint issue

* fixed some of the tests issues

* fixed test_core.py error

* added tests and fixed computeBoxCorners()

* added python tests

* fixed format errors

* Update tests/test-vector-ops.js

Co-authored-by: Marcus Fedarko <[email protected]>
Co-authored-by: Marcus Fedarko <[email protected]>
Co-authored-by: Yoshiki Vázquez Baeza <[email protected]>
Co-authored-by: Yoshiki Vázquez Baeza <[email protected]>
  • Loading branch information
5 people authored Jun 19, 2020
1 parent db462fa commit a994c47
Show file tree
Hide file tree
Showing 10 changed files with 804 additions and 146 deletions.
Binary file modified docs/moving-pictures/empress-tree.qzv
Binary file not shown.
18 changes: 15 additions & 3 deletions empress/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()}
Expand Down
335 changes: 226 additions & 109 deletions empress/support_files/js/empress.js

Large diffs are not rendered by default.

79 changes: 66 additions & 13 deletions empress/support_files/js/vector-ops.js
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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,
Expand All @@ -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;
}
Expand All @@ -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,
};
});
177 changes: 166 additions & 11 deletions empress/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand All @@ -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
Expand All @@ -156,23 +157,29 @@ 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

# Determine highest and lowest child y-position for internal nodes in
# 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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit a994c47

Please sign in to comment.