"""
Classes and functions for drawing a tree.
"""
from math import sin, cos, pi, sqrt, atan2
from collections import namedtuple, OrderedDict, defaultdict, deque
import random
from time import time
from ete4.core import operations as ops
from .. import TreeStyle
from .face_positions import FACE_POSITIONS, make_faces
from . import draw_helpers as dh
Box = dh.Box # shortcut, because we use it a lot
Size = namedtuple('Size', 'dx dy') # size of a 2D shape (sizes are always >= 0)
TreeActive = namedtuple('TreeActive', 'nodes clades')
Active = namedtuple('Active', 'results parents')
[docs]
def get_empty_active():
nodes = Active(set(), defaultdict(lambda: 0))
clades = Active(set(), defaultdict(lambda: 0))
return TreeActive(nodes, clades)
# The coordinates (x, y, dx, dy) are all "generalized coordinates" (x and y
# can refer to radius and angle, for example).
# The convention for coordinates is:
# x increases to the right, y increases to the bottom.
#
# +-----> x +------.
# | \ .
# | \ . a (the angle thus increases clockwise too)
# v y r \.
#
# This is the convention normally used in computer graphics, including SVGs,
# HTML Canvas, Qt, and PixiJS.
#
# The boxes (shapes) we use are:
#
# * Rectangle w
# x,y +-----+ so (x,y) is its (left,top) corner
# | | h and (x+w,y+h) its (right,bottom) one
# +-----+
#
# * Annular sector dr
# r,a .----.
# . . so (r,a) is its (inner,smaller-angle) corner
# \ . da and (r+dr,a+da) its (outer,bigger-angle) one
# \.
# Drawing.
[docs]
def safe_string(prop):
if type(prop) in (int, float, str):
return prop
try:
return str(prop)
except:
return ""
[docs]
class Drawer:
"Base class (needs subclassing with extra functions to draw)"
COLLAPSE_SIZE = 6 # anything that has less pixels will be outlined
MIN_SIZE = 1 # anything that has less pixels will not be drawn
TYPE = 'base' # can be 'rect' or 'circ' for working drawers
NPANELS = 1 # number of drawing panels (including the aligned ones)
[docs]
def __init__(self, tree, viewport=None, panel=0, zoom=(1, 1),
limits=None, collapsed_ids=None,
active=None, selected=None, searches=None,
layouts=None, tree_style=None,
include_props=None, exclude_props=None):
self.tree = tree
self.viewport = Box(*viewport) if viewport else None
self.panel = panel
self.zoom = zoom
self.xmin, self.xmax, self.ymin, self.ymax = limits or (0, 0, 0, 0)
self.collapsed_ids = collapsed_ids or set() # manually collapsed
self.active = active or get_empty_active() # looks like (results, parents)
self.selected = selected or {} # looks like {node_id: (node, parents)}
self.searches = searches or {} # looks like {text: (results, parents)}
self.layouts = layouts or []
self.include_props = include_props
self.exclude_props = exclude_props
self.tree_style = tree_style or TreeStyle()
[docs]
def draw(self):
"Yield graphic elements to draw the tree"
self.outline = None # box surrounding the current collapsed nodes
self.collapsed = [] # nodes that are curretly collapsed together
self.nodeboxes = [] # boxes surrounding all nodes and collapsed boxes
self.node_dxs = [[]] # lists of nodes dx (to find the max)
self.bdy_dys = [[]] # lists of branch dys and total dys
if self.panel == 0:
self.tree_style.aligned_grid_dxs = defaultdict(lambda: 0)
if self.panel in (2, 3):
yield from self.draw_aligned_headers()
if self.panel == -1:
yield from self.tree_style.get_legend()
else:
point = self.xmin, self.ymin
for it in ops.walk(self.tree):
graphics = []
if it.first_visit:
point = self.on_first_visit(point, it, graphics)
else:
point = self.on_last_visit(point, it, graphics)
yield from graphics
if self.outline:
yield from self.get_outline() # send last surrounding outline
if self.panel == 0:
max_dx = max([box[1].dx for box in self.nodeboxes] + [0])
self.tree_style.aligned_grid_dxs[-1] = max_dx
# To draw in preorder the boxes we found in postorder.
yield from self.nodeboxes[::-1] # (so they overlap nicely)
[docs]
def on_first_visit(self, point, it, graphics):
"Update list of graphics to draw and return new position"
box_node = make_box(point, self.node_size(it.node))
x, y = point
it.node.is_collapsed = False
if not self.in_viewport(box_node):
self.bdy_dys[-1].append( (box_node.dy / 2, box_node.dy) )
it.descend = False # skip children
return x, y + box_node.dy
if not it.node.sm_style['draw_descendants']:
# Skip descendants => in collapsed_ids
self.collapsed_ids.add(it.node_id)
is_manually_collapsed = it.node_id in self.collapsed_ids
if is_manually_collapsed and self.outline:
graphics += self.get_outline() # so we won't stack with its outline
if is_manually_collapsed or self.is_small(box_node):
self.node_dxs[-1].append(box_node.dx)
self.collapsed.append(it.node)
self.outline = stack(self.outline, box_node)
it.descend = False # skip children
return x, y + box_node.dy
if self.outline:
graphics += self.get_outline()
self.bdy_dys.append([])
dx, dy = self.content_size(it.node)
if it.node.is_leaf:
return self.on_last_visit((x + dx, y + dy), it, graphics)
else:
self.node_dxs.append([])
return x + dx, y
[docs]
def on_last_visit(self, point, it, graphics):
"Update list of graphics to draw and return new position"
# Searches
searched_by = set( text for text,(results,_) in self.searches.items()
if it.node in results )
# Selection
selected_by = [ text for text,(results,_) in self.selected.items()
if it.node in results ]
active_clade = [ "active_clades" ] if it.node in self.active.clades.results else []
# Only if node is collapsed
selected_children = []
active_children = TreeActive(0, 0)
if self.outline:
if all(child in self.collapsed for child in it.node.children):
searched_by.update( text for text,(results,parents) in self.searches.items()
if any(node in results or node in parents.keys() for node in self.collapsed) )
active_children = self.get_active_children()
selected_children = self.get_selected_children()
graphics += self.get_outline()
x_after, y_after = point
dx, dy = self.content_size(it.node)
x_before, y_before = x_after - dx, y_after - dy
content_graphics = list(self.draw_content(it.node, (x_before, y_before),
active_children, selected_children))
graphics += content_graphics
ndx = (drawn_size(content_graphics, self.get_box).dx if it.node.is_leaf
else (dx + max(self.node_dxs.pop() or [0])))
self.node_dxs[-1].append(ndx)
box = Box(x_before, y_before, ndx, dy)
self.nodeboxes += self.draw_nodebox(it.node, it.node_id, box,
list(searched_by) + selected_by + active_clade,
{ 'fill': it.node.sm_style.get('bgcolor') })
return x_before, y_after;
[docs]
def draw_content(self, node, point, active_children=TreeActive(0, 0), selected_children=[]):
"Yield the node content's graphic elements"
x, y = point
dx, dy = self.content_size(node)
# Find branch dy of first child (bdy0), last (bdy1), and self (bdy).
bdy_dys = self.bdy_dys.pop() # bdy_dys[i] == (bdy, dy)
bdy0 = bdy1 = dy / 2 # branch dys of the first and last children
if bdy_dys:
bdy0 = bdy_dys[0][0]
bdy1 = sum(bdy_dy[1] for bdy_dy in bdy_dys[:-1]) + bdy_dys[-1][0]
bdy = (bdy0 + bdy1) / 2 # this node's branch dy
self.bdy_dys[-1].append( (bdy, dy) )
# Collapsed nodes will be drawn from self.draw_collapsed()
if not node.is_collapsed or node.is_leaf:
bdy0_, bdy1_ = (0, dy) if node.is_leaf else (bdy0, bdy1)
yield from self.draw_node(node, point, dx, bdy, bdy0_, bdy1_,
active_children, selected_children)
# Draw the branch line ("lengthline") and a line spanning all children.
if self.panel == 0:
node_style = node.sm_style
if dx > 0:
parent_of = set(text for text,(_,parents) in self.searches.items()
if node in parents.keys())
parent_of.update(text for text,(_,parents) in self.selected.items()
if node in parents.keys())
hz_line_style = {
'type': node_style['hz_line_type'],
'stroke-width': node_style['hz_line_width'],
'stroke': node_style['hz_line_color'],
}
yield from self.draw_lengthline((x, y + bdy), (x + dx, y + bdy),
list(parent_of), style=hz_line_style)
if bdy0 != bdy1:
vt_line_style = {
'type': node_style['vt_line_type'],
'stroke-width': node_style['vt_line_width'],
'stroke': node_style['vt_line_color'],
}
yield from self.draw_childrenline((x + dx, y + bdy0),
(x + dx, y + bdy1),
style=vt_line_style)
active_node = "selected_results_active_nodes"\
if node in self.active.nodes.results else ""
nodedot_style = {
'shape': node_style['shape'],
'size': node_style['size'],
'fill': node_style['fgcolor'],
'opacity': node_style['fgopacity'],
}
yield from self.draw_nodedot((x + dx, y + bdy),
dy * self.zoom[1], active_node, nodedot_style)
[docs]
def get_outline(self):
"""Yield the outline representation."""
graphics = [] # will contain the graphic elements to draw
node0 = self.collapsed[0]
uncollapse = len(self.collapsed) == 1 and node0.is_leaf
x, y, _, _ = self.outline
collapsed_node = self.get_collapsed_node()
searched_by = [text for text,(results,parents) in self.searches.items()
if collapsed_node in results
or any(node in results or node in parents
for node in self.collapsed)]
selected_by = [text for text,(results,parents) in self.selected.items()
if collapsed_node in results]
active_clade = [ "active_clades" ] if collapsed_node in self.active.clades.results else []
active_children = self.get_active_children()
selected_children = self.get_selected_children()
if uncollapse:
self.bdy_dys.append([])
graphics += self.draw_content(node0, (x, y))
else:
self.bdy_dys[-1].append( (self.outline.dy / 2, self.outline.dy) )
graphics += self.draw_collapsed(collapsed_node, active_children, selected_children)
is_manually_collapsed = collapsed_node in self.collapsed
is_small = self.is_small(make_box((x, y),
self.node_size(collapsed_node)))
self.collapsed = []
ndx = max(self.outline.dx, drawn_size(graphics, self.get_box).dx)
self.node_dxs[-1].append(ndx)
# Draw collapsed node nodebox when necessary
if is_manually_collapsed or is_small or dist(collapsed_node) == 0:
name = collapsed_node.name
properties = self.get_popup_props(collapsed_node)
node_id = tuple(collapsed_node.id) if is_manually_collapsed else []
box = dh.draw_nodebox(self.flush_outline(ndx), name,
properties, node_id, searched_by + selected_by + active_clade,
{ 'fill': collapsed_node.sm_style.get('bgcolor') })
self.nodeboxes.append(box)
else:
self.flush_outline()
yield from graphics
[docs]
def flush_outline(self, minimum_dx=0):
"Return box outlining the collapsed nodes and reset the current outline"
x, y, dx, dy = self.outline
self.outline = None
return Box(x, y, max(dx, minimum_dx), dy)
[docs]
def get_collapsed_node(self):
"""Get node that will be rendered as a collapsed node.
Either the only node collapsed or the parent of all collapsed nodes."""
node0 = self.collapsed[0]
if len(self.collapsed) == 1:
node0.is_collapsed = True
return node0
parent = node0.up
if all(node.up == parent for node in self.collapsed[1:]) and\
all(child in self.collapsed for child in parent.children):
parent.is_collapsed = True
return parent
# No node inside the tree contains all the collapsed nodes
# Create a fictional node whose children are the collapsed nodes
try:
node = Tree()
except:
from ... import Tree # avoid circular import
node = Tree()
node.is_collapsed = True
node.is_initialized = False
node._children = self.collapsed # add avoiding parent override
_, _, _, dy = self.outline
node.dist = 0
node.size = Size(0, dy)
return node
[docs]
def is_fully_collapsed(self, collapsed_node):
"""Returns true if collapsed_node is utterly collapsed,
i.e. has no branch width"""
x, y, _, _ = self.outline
is_manually_collapsed = collapsed_node in self.collapsed
box_node = make_box((x, y), self.node_size(collapsed_node))
return is_manually_collapsed or self.is_small(box_node)
[docs]
def get_active_children(self):
nodes = sum(1 for node in self.collapsed if node in self.active.nodes.results)
nodes += sum(self.active.nodes.parents.get(node, 0) for node in self.collapsed)
clades = sum(len(node) for node in self.collapsed if node in self.active.clades.results)
clades += sum(self.active.clades.parents.get(node, 0) for node in self.collapsed)
return TreeActive(nodes, clades)
[docs]
def get_selected_children(self):
selected_children = []
for text,(results, parents) in self.selected.items():
hits = sum(1 for node in self.collapsed if node in results)
hits += sum(parents.get(node, 0) for node in self.collapsed)
if hits:
selected_children.append((text, hits))
return selected_children
# NOTE: We do it this way so the properties appear in the
# order given in include_props.
# These are the 2 functions that the user overloads to choose what to draw
# when representing a node and a group of collapsed nodes:
[docs]
def draw_node(self, node, point, bdx, bdy, bdy0, bdy1, active_children=TreeActive(0, 0), selected_children=[]):
"Yield graphic elements to draw the contents of the node"
# bdx: branch dx (width)
# bdy: branch dy (height)
# bdy0: fist child branch dy (height)
# bdy1: last child branch dy (height)
# selected_children: list of selected nodes (under this node if collapsed or
# this node if not collapsed) (to be tagged in front end)
yield from [] # only drawn if the node's content is visible
[docs]
def draw_collapsed(self, collapsed_node, active_children=TreeActive(0, 0), selected_children=[]):
"Yield graphic elements to draw the list of nodes in self.collapsed"
# selected_children: list of selected nodes under this node
yield from [] # they are always drawn (only visible nodes can collapse)
# Uses self.collapsed and self.outline to extract and place info.
[docs]
class DrawerRect(Drawer):
"Minimal functional drawer for a rectangular representation"
TYPE = 'rect'
[docs]
def in_viewport(self, box, pos=None):
if not self.viewport:
return True
if self.panel == 0 and pos != 'aligned':
return dh.intersects_box(self.viewport, box)
else:
return dh.intersects_segment(dh.get_ys(self.viewport), dh.get_ys(box))
[docs]
def node_size(self, node):
"Return the size of a node (its content and its children)"
return Size(node.size[0], node.size[1])
[docs]
def content_size(self, node):
"Return the size of the node's content"
return Size(dist(node), node.size[1])
[docs]
def is_small(self, box):
zx, zy, _ = self.zoom
return box.dy * zy < self.COLLAPSE_SIZE
[docs]
def get_box(self, element):
zx, zy, za = self.zoom
if self.panel == 0:
zoom = (zx, zy)
else:
zoom = (za, zy)
return get_rect(element, zoom)
[docs]
def draw_lengthline(self, p1, p2, parent_of, style):
"Yield a line representing a length"
line = dh.draw_line(p1, p2, 'lengthline', parent_of, style)
if not self.viewport or dh.intersects_box(self.viewport, get_rect(line)):
yield line
[docs]
def draw_childrenline(self, p1, p2, style):
"Yield a line spanning children that starts at p1 and ends at p2"
line = dh.draw_line(p1, p2, 'childrenline', style=style)
if not self.viewport or dh.intersects_box(self.viewport, get_rect(line)):
yield line
[docs]
def draw_nodedot(self, center, max_size, active_node, style):
"Yield circle or square on node based on node.sm_style"
size = min(max_size, style['size'])
if active_node:
size = max(min(max_size, 4), size)
if size > 0:
fill = style['fill']
nodedot_style={'fill':fill, 'opacity': style['opacity']}
if style['shape'] == 'circle':
yield dh.draw_circle(center, radius=size,
circle_type='nodedot ' + active_node, style=nodedot_style)
elif style['shape'] == 'square':
x, y = center
zx, zy, _ = self.zoom
dx, dy = 2 * size / zx, 2 * size / zy
box = (x - dx/2, y - dy/2, dx, dy)
yield dh.draw_rect(box, rect_type='nodedot ' + active_node, style=nodedot_style)
elif style['shape'] == "triangle":
x, y = center
zx, zy, _ = self.zoom
dx, dy = 2 * size / zx, 2 * size / zy
box = (x - dx/2, y - dy/2, dx, dy)
yield dh.draw_triangle(box, "top", triangle_type='nodedot ' + active_node, style=nodedot_style)
[docs]
def draw_nodebox(self, node, node_id, box, searched_by, style=None):
yield dh.draw_nodebox(box, node.name, self.get_popup_props(node),
node_id, searched_by, style)
[docs]
def draw_collapsed(self, collapsed_node, active_children=TreeActive(0, 0), selected_children=[]):
# Draw line to farthest leaf under collapsed node
x, y, dx, dy = self.outline
p1 = (x, y + dy / 2)
p2 = (x + dx, y + dy / 2)
yield dh.draw_line(p1, p2, 'lengthline')
[docs]
class DrawerCirc(Drawer):
"Minimal functional drawer for a circular representation"
TYPE = 'circ'
[docs]
def __init__(self, tree, viewport=None, panel=0, zoom=(1, 1),
limits=None, collapsed_ids=None, active=None,
selected=None, searches=None,
layouts=None, tree_style=None,
include_props=None, exclude_props=None):
super().__init__(tree, viewport, panel, zoom,
limits, collapsed_ids, active, selected, searches,
layouts, tree_style,
include_props=include_props,
exclude_props=exclude_props)
assert self.zoom[0] == self.zoom[1], 'zoom must be equal in x and y'
if not limits:
self.ymin, self.ymax = -pi, pi
self.dy2da = (self.ymax - self.ymin) / self.tree.size[1]
[docs]
def in_viewport(self, box, pos=None):
if not self.viewport:
return dh.intersects_segment((-pi, +pi), dh.get_ys(box))
if self.panel == 0 and pos != 'aligned':
return (dh.intersects_box(self.viewport, dh.circumrect(box)) and
dh.intersects_segment((-pi, +pi), dh.get_ys(box)))
else:
return dh.intersects_angles(self.viewport, box)
[docs]
def flush_outline(self, minimum_dr=0):
"Return box outlining the collapsed nodes"
r, a, dr, da = super().flush_outline(minimum_dr)
a1, a2 = dh.clip_angles(a, a + da)
return Box(r, a1, dr, a2 - a1)
[docs]
def node_size(self, node):
"Return the size of a node (its content and its children)"
return Size(node.size[0], node.size[1] * self.dy2da)
[docs]
def content_size(self, node):
"Return the size of the node's content"
return Size(dist(node), node.size[1] * self.dy2da)
[docs]
def is_small(self, box):
z = self.zoom[0] # zx == zy in this drawer
r, a, dr, da = box
return (r + dr) * da * z < self.COLLAPSE_SIZE
[docs]
def get_box(self, element):
return get_asec(element, self.zoom)
[docs]
def draw_lengthline(self, p1, p2, parent_of, style):
"Yield a line representing a length"
if -pi <= p1[1] < pi: # NOTE: the angles p1[1] and p2[1] are equal
yield dh.draw_line(dh.cartesian(p1), dh.cartesian(p2),
'lengthline', parent_of, style)
[docs]
def draw_childrenline(self, p1, p2, style):
"Yield an arc spanning children that starts at p1 and ends at p2"
(r1, a1), (r2, a2) = p1, p2
a1, a2 = dh.clip_angles(a1, a2)
if a1 < a2:
is_large = a2 - a1 > pi
yield dh.draw_arc(dh.cartesian((r1, a1)), dh.cartesian((r2, a2)),
is_large, 'childrenline', style=style)
[docs]
def draw_nodedot(self, center, max_size, active_node, style):
r, a = center
size = min(max_size, style['size'])
if active_node:
size = max(min(max_size, 4), size)
if -pi < a < pi and size > 0:
fill = style['fill']
nodedot_style={'fill':fill, 'opacity': style['opacity']}
if style['shape'] == 'circle':
yield dh.draw_circle(center, radius=size,
circle_type='nodedot ' + active_node, style=nodedot_style)
elif style['shape'] == 'square':
z = self.zoom[0] # same zoom in x and y
dr, da = 2 * size / z, 2 * size / (z * r)
box = Box(r - dr / 2, a - da / 2, dr, da)
yield dh.draw_rect(box, rect_type='nodedot ' + active_node, style=nodedot_style)
[docs]
def draw_nodebox(self, node, node_id, box, searched_by, style=None):
r, a, dr, da = box
a1, a2 = dh.clip_angles(a, a + da)
if a1 < a2:
yield dh.draw_nodebox(Box(r, a1, dr, a2 - a1),
node.name, self.get_popup_props(node), node_id, searched_by, style)
[docs]
def draw_collapsed(self, collapsed_node, active_children=TreeActive(0, 0), selected_children=[]):
# Draw line to farthest leaf under collapsed node
r, a, dr, da = self.outline
p1 = (r, a + da / 2)
p2 = (r + dr, a + da / 2)
yield dh.draw_line(dh.cartesian(p1), dh.cartesian(p2), 'lengthline')
# The actual drawers.
[docs]
class DrawerRectFaces(DrawerRect):
[docs]
def draw_node(self, node, point, bdx, bdy, bdy0, bdy1,
active_children=TreeActive(0, 0), selected_children=[]):
size = self.content_size(node)
# Space available for branch-right Face position
dx_to_closest_child = (dist(node) if node.is_leaf else
min(dist(child) for child in node.children))
zx, zy, za = self.zoom
def it_fits(box, pos):
z = za if pos == 'aligned' else zx
_, _, dx, dy = box
return (dx * z > self.MIN_SIZE and
dy * zy > self.MIN_SIZE and
self.in_viewport(box, pos))
def draw_face(face, pos, row, n_row, n_col, dx_before, dy_before):
if face.get_content():
box = face.compute_bounding_box(self, point, size,
dx_to_closest_child,
bdx, bdy, bdy0, bdy1,
pos, row, n_row, n_col,
dx_before, dy_before)
if (it_fits(box, pos) and face.fits()) or face.always_drawn:
yield from face.draw(self)
def draw_faces_at_pos(node, pos):
if node.is_collapsed and not node.is_leaf:
node_faces = node.collapsed_faces
else:
node_faces = node.faces
faces = dict(getattr(node_faces, pos, {}))
n_col = max(faces.keys(), default=-1) + 1
z = za if pos == 'aligned' else zx
# Add SelectedFace for each search this node is a result of
if pos == self.tree_style.selected_face_pos and len(selected_children):
faces[n_col] = [ self.tree_style.selected_face(s, text=v) for s,v in selected_children ]
n_col += 1
if pos == self.tree_style.active_face_pos:
active_faces = []
nodes = active_children.nodes
if nodes > 0:
active_faces.append(self.tree_style.active_face("active_nodes", text=nodes))
clades = active_children.clades
if clades > 0:
active_faces.append(self.tree_style.active_face("active_clades", text=clades))
if active_faces:
faces[n_col] = active_faces
n_col += 1
dx_before = 0
for col, face_list in sorted(faces.items()):
if pos == 'aligned'\
and self.tree_style.aligned_grid\
and self.NPANELS > 1\
and self.panel > 0\
and col > 0:
# Avoid changing-size error when zooming very quickly
dxs = list(self.tree_style.aligned_grid_dxs.items())
dx_before = sum(v for k, v in dxs if k < col and k >= 0)
dx_max = 0
dy_before = 0
n_row = len(face_list)
for row, face in enumerate(face_list):
face.node = node
drawn_face = list(draw_face(face, pos, row, n_row, n_col,
dx_before, dy_before))
if drawn_face:
_, _, dx, dy = face.get_box()
hz_padding = 2 * face.padding_x / z
vt_padding = 2 * face.padding_y / zy
# NOTE: This is a hack to align nicely the headers in LayoutBarPlot.
hfaces = self.tree_style._aligned_panel_header.get(col) # header faces
if col > 0 and hfaces:
dx_max = max((hface.width for hface in hfaces if hface.width), default=0) + hz_padding # it's possible width is none
dx_max = max(dx_max, (dx or 0) + hz_padding)
dy_before += dy + vt_padding
yield from drawn_face
# Update dx_before
if pos == 'aligned'\
and self.tree_style.aligned_grid\
and self.NPANELS > 1:
dx_grid = self.tree_style.aligned_grid_dxs[col]
if self.panel == 0:
# Compute aligned grid
dx_grid = max(dx_grid, dx_max)
self.tree_style.aligned_grid_dxs[col] = dx_grid
else:
dx_before += dx_grid
else:
dx_before += dx_max
if not node.is_initialized:
node.is_initialized = True
node.faces = make_faces()
node.collapsed_faces = make_faces()
for layout in self.layouts:
layout.set_node_style(node)
# Render Faces in different panels
if self.NPANELS > 1:
if self.panel == 0:
for pos in FACE_POSITIONS[:3]:
yield from draw_faces_at_pos(node, pos)
# Only run function to compute aligned grid
if self.tree_style.aligned_grid:
deque(draw_faces_at_pos(node, 'aligned'))
elif self.panel == 1:
yield from draw_faces_at_pos(node, 'aligned')
else:
for pos in FACE_POSITIONS:
yield from draw_faces_at_pos(node, pos)
[docs]
def draw_collapsed(self, collapsed_node, active_children=TreeActive(0, 0), selected_children=[]):
x, y, dx, dy = self.outline
if self.is_fully_collapsed(collapsed_node):
bdx = 0
else:
x = x - dist(collapsed_node)
bdx = dist(collapsed_node)
x = x if self.panel == 0 else self.xmin
yield from self.draw_node(collapsed_node,
(x, y), bdx, dy/2, 0, dy, active_children, selected_children)
[docs]
class DrawerCircFaces(DrawerCirc):
[docs]
def draw_node(self, node, point, bdr, bda, bda0, bda1, active_children=TreeActive(0, 0), selected_children=[]):
size = self.content_size(node)
# Space available for branch-right Face position
dr_to_closest_child = min(dist(child) for child in node.children)\
if not (node.is_leaf or node.is_collapsed) else dist(node)
z = self.zoom[0] # zx == zy
def it_fits(box, pos):
r, a, dr, da = box
return r > 0 \
and dr * z > self.MIN_SIZE\
and (r + dr) * da * z > self.MIN_SIZE\
and self.in_viewport(box, pos)
def draw_face(face, pos, row, n_row, n_col, dr_before, da_before):
if face.get_content():
box = face.compute_bounding_box(self, point, size,
dr_to_closest_child,
bdr, bda, bda0, bda1,
pos, row, n_row, n_col,
dr_before, da_before)
if (it_fits(box, pos) and face.fits()) or face.always_drawn:
yield from face.draw(self)
def draw_faces_at_pos(node, pos):
if node.is_collapsed and not node.is_leaf:
node_faces = node.collapsed_faces
else:
node_faces = node.faces
faces = dict(getattr(node_faces, pos, {}))
n_col = len(faces.keys())
# Add SelectedFace for each search this node is a result of
if pos == self.tree_style.selected_face_pos and len(selected_children):
faces[n_col] = [ self.tree_style.selected_face(s) for s in selected_children ]
n_col += 1
if pos == self.tree_style.active_face_pos:
active_faces = []
nodes = active_children.nodes
if nodes > 0:
active_faces.append(self.tree_style.active_face("active_nodes", text=nodes))
clades = active_children.clades
if clades > 0:
active_faces.append(self.tree_style.active_face("active_clades", text=clades))
if active_faces:
faces[n_col] = active_faces
n_col += 1
# Avoid drawing faces very close to center
if pos.startswith('branch-') and abs(point[0]) < 1e-5:
n_col += 1
dr_before = .7 * size[0] / n_col
else:
dr_before = 0
for col, face_list in sorted(faces.items()):
if pos == 'aligned'\
and self.tree_style.aligned_grid\
and self.NPANELS > 1:
# Avoid changing-size error when zooming very quickly
drs = list(self.tree_style.aligned_grid_dxs.items())
dr_before = sum(v for k, v in drs if k < col)
dr_max = 0
da_before = 0
n_row = len(face_list)
for row, face in enumerate(face_list):
face.node = node
drawn_face = list(draw_face(face, pos, row, n_row, n_col,
dr_before, da_before))
if drawn_face:
r, a, dr, da = face.get_box()
hz_padding = 2 * face.padding_x / z
vt_padding = 2 * face.padding_y / (z * (r or 1e-10))
dr_max = max(dr_max, dr + hz_padding)
da_before = da + vt_padding
yield from drawn_face
# Update dr_before
if pos == 'aligned'\
and self.tree_style.aligned_grid\
and self.NPANELS > 1:
dr_grid = self.tree_style.aligned_grid_dxs[col]
if self.panel == 0:
# Compute aligned grid
dr_grid = max(dr_grid, dr_max)
self.tree_style.aligned_grid_dxs[col] = dr_grid
else:
dr_before += dr_grid
else:
dr_before += dr_max
if not node.is_initialized:
node.is_initialized = True
for layout in self.layouts:
layout.set_node_style(node)
# Render Faces in different panels
if self.NPANELS > 1:
if self.panel == 0:
for pos in FACE_POSITIONS[:3]:
yield from draw_faces_at_pos(node, pos)
# Only run function to compute aligned grid
if self.tree_style.aligned_grid:
deque(draw_faces_at_pos(node, 'aligned'))
elif self.panel == 1:
yield from draw_faces_at_pos(node, 'aligned')
else:
for pos in FACE_POSITIONS:
yield from draw_faces_at_pos(node, pos)
[docs]
def draw_collapsed(self, collapsed_node, active_children=TreeActive(0, 0), selected_children=[]):
r, a, dr, da = self.outline
if self.is_fully_collapsed(collapsed_node):
bdr = 0
else:
r = r - dist(collapsed_node)
bdr = dist(collapsed_node)
r = r if self.panel == 0 else self.xmin
yield from self.draw_node(collapsed_node,
(r, a), bdr, da/2, 0, da, active_children, selected_children)
[docs]
class DrawerAlignRectFaces(DrawerRectFaces):
NPANELS = 4
[docs]
class DrawerAlignCircFaces(DrawerCircFaces):
NPANELS = 2
[docs]
def get_drawers():
return [ DrawerRect, DrawerCirc,
DrawerRectFaces, DrawerCircFaces,
DrawerAlignRectFaces, DrawerAlignCircFaces, ]
# Box-related functions.
[docs]
def make_box(point, size):
x, y = point
dx, dy = size
return Box(x, y, dx, dy)
[docs]
def get_rect(element, zoom=(0, 0)):
"Return the rectangle that contains the given graphic element"
eid = element[0] # elements are tuples with element-id in the 1st place
if eid in ['nodebox', 'rect', 'array', 'text', 'triangle', 'html', 'img']:
return element[1]
elif eid == 'outline':
x, y, dx, dy = element[1]
return Box(x, y, dx, dy)
elif eid.startswith('pixi-'):
x, y, dx, dy = element[1]
return Box(x, y, dx, dy)
elif eid == 'rhombus':
points = element[1]
x = points[3][0]
y = points[0][1]
dx = points[2][0] - x
dy = points[2][0] - y
return Box(x, y, dx, dy)
elif eid == 'polygon':
min_x = min(p[0] for p in element[1])
max_x = max(p[0] for p in element[1])
min_y = min(p[1] for p in element[1])
max_y = max(p[1] for p in element[1])
return Box(min_x, min_y, max_x - min_x, max_y - min_y)
elif eid in ['line', 'arc']: # not a great approximation for an arc...
(x1, y1), (x2, y2) = element[1], element[2]
return Box(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1))
elif eid == 'circle':
(x, y), r = element[1], element[2]
zx, zy = zoom
rx, ry = r / zx, r / zy
return Box(x - rx, y - ry, 2 * rx, 2 * ry)
elif eid == 'ellipse':
(x, y), rx, ry = element[1:4]
zx, zy = zoom
rx, ry = rx / zx, ry / zy
rx, ry = 0, 0
return Box(x - rx, y - ry, 2 * rx, 2 * ry)
elif eid == 'slice':
(x, y), r = element[1][0], element[1][1]
zx, zy = zoom
rx, ry = r / zx, r / zy
return Box(x - rx, y - ry, 2 * rx, 2 * ry)
else:
raise ValueError(f'unrecognized element: {element!r}')
[docs]
def get_asec(element, zoom=(0, 0)):
"Return the annular sector that contains the given graphic element"
eid = element[0] # elements are tuples with element-id in the 1st place
if eid in ['nodebox', 'rect', 'array', 'text', 'triangle', 'html', 'img']:
return element[1]
elif eid == 'outline':
r, a, dr, da = element[1]
return Box(r, a, dr, da)
elif eid.startswith('pixi-'):
x, y, dx, dy = element[1]
return Box(x, y, dx, dy)
elif eid == 'rhombus':
points = element[1]
r = points[3][0]
a = points[0][1]
dr = points[2][0] - r
da = points[2][0] - a
return Box(r, a, dr , da)
elif eid == 'polygon':
min_x = min(p[0] for p in element[1])
max_x = max(p[0] for p in element[1])
min_y = min(p[1] for p in element[1])
max_y = max(p[1] for p in element[1])
return Box(min_x, min_y, max_x - min_x, max_y - min_y)
elif eid in ['line', 'arc']:
(x1, y1), (x2, y2) = element[1], element[2]
rect = Box(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1))
return dh.circumasec(rect)
elif eid == 'circle':
z = zoom[0]
(x, y), r = dh.cartesian(element[1]), element[2] / z
rect = Box(x - r, y - r, 2 * r, 2 * r)
return dh.circumasec(rect)
elif eid == 'ellipse':
x, y = dh.cartesian(element[1])
z = zoom[0]
rx, ry = element[2] / z, element[3] / z
rect = Box(x - rx, y - ry, 2 * rx, 2 * ry)
return dh.circumasec(rect)
elif eid == 'slice':
z = zoom[0]
(x, y), r = dh.cartesian(element[1][0]), element[1][1] / z
rect = Box(x - r, y - r, 2 * r, 2 * r)
return dh.circumasec(rect)
else:
raise ValueError(f'unrecognized element: {element!r}')
[docs]
def drawn_size(elements, get_box, min_x=None):
"Return the size of a box containing all the elements"
# The type of size will depend on the kind of boxes that are returned by
# get_box() for the elements. It is width and height for boxes that are
# rectangles, and dr and da for boxes that are annular sectors.
if not elements:
return Size(0, 0)
x, y, dx, dy = get_box(elements[0])
x_min, x_max = x, x + dx
y_min, y_max = y, y + dy
for element in elements[1:]:
x, y, dx, dy = get_box(element)
x_min, x_max = min(x_min, x), max(x_max, x + dx)
y_min, y_max = min(y_min, y), max(y_max, y + dy)
# Constrains x_min
# Necessary for collapsed nodes with branch-top/bottom faces
if min_x:
x_min = max(x_min, min_x)
return Size(x_max - x_min, y_max - y_min)
[docs]
def dist(node):
"""Return the distance of a node, with default values if not set."""
return float(node.props.get('dist', 0 if node.up is None else 1))
[docs]
def stack(box1, box2):
"""Return the box resulting from stacking the given boxes."""
if not box1:
return box2
else:
x, y, dx1, dy1 = box1
_, _, dx2, dy2 = box2
return Box(x, y, max(dx1, dx2), dy1 + dy2)