import base64
from collections.abc import Iterable
import pathlib
import re
from math import pi
from ..utils import InvalidUsage, get_random_string
from .draw_helpers import *
from copy import deepcopy
CHAR_HEIGHT = 1.4 # char's height to width ratio
ALLOWED_IMG_EXTENSIONS = [ "png", "svg", "jpeg" ]
_aacolors = {
'A':"#C8C8C8" ,
'R':"#145AFF" ,
'N':"#00DCDC" ,
'D':"#E60A0A" ,
'C':"#E6E600" ,
'Q':"#00DCDC" ,
'E':"#E60A0A" ,
'G':"#EBEBEB" ,
'H':"#8282D2" ,
'I':"#0F820F" ,
'L':"#0F820F" ,
'K':"#145AFF" ,
'M':"#E6E600" ,
'F':"#3232AA" ,
'P':"#DC9682" ,
'S':"#FA9600" ,
'T':"#FA9600" ,
'W':"#B45AB4" ,
'Y':"#3232AA" ,
'V':"#0F820F" ,
'B':"#FF69B4" ,
'Z':"#FF69B4" ,
'X':"#BEA06E",
'.':"#FFFFFF",
'-':"#FFFFFF",
}
_ntcolors = {
'A':'#A0A0FF',
'G':'#FF7070',
'I':'#80FFFF',
'C':'#FF8C4B',
'T':'#A0FFA0',
'U':'#FF8080',
'.':"#FFFFFF",
'-':"#FFFFFF",
' ':"#FFFFFF"
}
__all__ = [
'Face', 'TextFace', 'AttrFace', 'CircleFace', 'RectFace',
'ArrowFace', 'SelectedFace', 'SelectedCircleFace',
'SelectedRectFace', 'OutlineFace', 'AlignLinkFace', 'SeqFace',
'SeqMotifFace', 'AlignmentFace', 'ScaleFace', 'PieChartFace',
'HTMLFace', 'ImgFace', 'LegendFace', 'StackedBarFace']
def clean_text(text):
return re.sub(r'[^A-Za-z0-9_-]', '', text)
def swap_pos(pos):
if pos == 'branch_top':
return 'branch_bottom'
elif pos == 'branch_bottom':
return 'branch_top'
else:
return pos
def stringify(content):
if type(content) in (str, float, int):
return str(content)
if isinstance(content, Iterable):
return ",".join(map(str, content))
return str(content)
[docs]
class Face:
"""
Base class for faces.
Ete uses "faces" to show some piece of information from
a node in a tree (as text or graphics of many kinds).
"""
[docs]
def __init__(self, name="", padding_x=0, padding_y=0):
self.node = None
self.name = name
self._content = "Empty"
self._box = None
self.padding_x = padding_x
self.padding_y = padding_y
self.always_drawn = False # Use carefully to avoid overheading...
self.zoom = (0, 0)
self.stretch = False # Stretch width independently of height
self.viewport = None # Aligned panel viewport (x1, x2)
self.viewport_margin = 100
def __name__(self):
return "Face"
[docs]
def in_aligned_viewport(self, segment):
if self.viewport:
return intersects_segment(self.viewport, segment)
return True
[docs]
def get_content(self):
return self._content
[docs]
def get_box(self):
self._check_own_variables()
return self._box
[docs]
def compute_fsize(self, dx, dy, zx, zy, max_fsize=None):
self._fsize = min([dx * zx * CHAR_HEIGHT, abs(dy * zy), max_fsize or self.max_fsize])
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
self._check_own_content()
x, y = point
dx, dy = size
zx, zy, za = drawer.zoom
if pos.startswith("aligned"):
zx = za
self.zoom = (zx, zy)
if pos == 'branch_top': # above the branch
avail_dx = dx / n_col
avail_dy = bdy / n_row
x = x + dx_before
y = y + bdy - avail_dy - dy_before
elif pos == 'branch_bottom': # below the branch
avail_dx = dx / n_col
avail_dy = (dy - bdy) / n_row
x = x + dx_before
y = y + bdy + dy_before
elif pos == 'branch_right': # right of node
avail_dx = dx_to_closest_child / n_col\
if not (self.node.is_leaf or self.node.is_collapsed)\
else None
avail_dy = min([bdy, dy - bdy, bdy - bdy0, bdy1 - bdy]) * 2 / n_row
x = x + bdx + dx_before
y = y + bdy + (row - n_row / 2) * avail_dy
elif pos.startswith('aligned'): # right of tree
avail_dx = None # should be overriden
avail_dy = dy / n_row
aligned_x = drawer.node_size(drawer.tree)[0]\
if drawer.panel == 0 else drawer.xmin
x = aligned_x + dx_before
if pos == 'aligned_bottom':
y = y + dy - avail_dy - dy_before
elif pos == 'aligned_top':
y = y + dy_before
else:
y = y + dy / 2 + (row - n_row / 2) * avail_dy
else:
raise InvalidUsage(f'unkown position {pos}')
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
padding_x = self.padding_x / zx
padding_y = self.padding_y / (zy * r)
self._box = Box(
x + padding_x,
y + padding_y,
# avail_dx may not be initialized for branch_right and aligned
max(avail_dx - 2 * padding_x, 0) if avail_dx else None,
max(avail_dy - 2 * padding_y, 0))
return self._box
[docs]
def fits(self):
"""
Return True if Face fits in computed box.
Method overriden by inheriting classes.
"""
return True
def _check_own_content(self):
if not self._content:
raise Exception(f'**Content** has not been computed yet.')
def _check_own_variables(self):
if not self._box:
raise Exception(f'**Box** has not been computed yet.\
\nPlease run `compute_bounding_box()` first')
self._check_own_content()
return
[docs]
class TextFace(Face):
[docs]
def __init__(self, text, name='', color='black',
min_fsize=6, max_fsize=15, ftype='sans-serif',
padding_x=0, padding_y=0, width=None, rotation=None):
# NOTE: if width is passed as an argument, then it is not
# computed from fit_fontsize() (this is part of a temporary
# hack to make LayoutBarPlot work).
# FIXME: The rotation is not being taken into account when
# computing the bounding box.
Face.__init__(self, name=name,
padding_x=padding_x, padding_y=padding_y)
self._content = stringify(text)
self.color = color
self.min_fsize = min_fsize
self.max_fsize = max_fsize
self._fsize = max_fsize
self.rotation = rotation
self.width = width
self.ftype = ftype
def __name__(self):
return "TextFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
pos = swap_pos(pos)
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
zx, zy = self.zoom
x, y , dx, dy = box
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
def fit_fontsize(text, dx, dy):
dchar = dx / len(text) if dx != None else float('inf')
self.compute_fsize(dchar, dy, zx, zy)
dxchar = self._fsize / (zx * CHAR_HEIGHT)
dychar = self._fsize / (zy * r)
return dxchar * len(text), dychar
# FIXME: Temporary hack to make the headers of LayoutBarPlot work.
if self.width:
width = self.width
_, height = fit_fontsize(self._content, dx, dy * r)
else:
width, height = fit_fontsize(self._content, dx, dy * r)
if pos == 'branch_top':
box = (x, y + dy - height, width, height) # container bottom
elif pos == 'branch_bottom':
box = (x, y, width, height) # container top
elif pos == 'aligned_bottom':
box = (x, y + dy - height, width, height)
elif pos == 'aligned_top':
box = (x, y, width, height)
else: # branch_right and aligned
box = (x, y + (dy - height) / 2, width, height)
self._box = Box(*box)
return self._box
[docs]
def fits(self):
return self._content != "None" and self._fsize >= self.min_fsize
[docs]
def draw(self, drawer):
self._check_own_variables()
style = {
'fill': self.color,
'max_fsize': self._fsize,
'ftype': f'{self.ftype}, sans-serif', # default sans-serif
}
yield draw_text(self._box,
self._content, self.name, rotation=self.rotation, style=style)
[docs]
class AttrFace(TextFace):
[docs]
def __init__(self, attr,
formatter=None,
name=None,
color="black",
min_fsize=6, max_fsize=15,
ftype="sans-serif",
padding_x=0, padding_y=0):
TextFace.__init__(self, text="",
name=name, color=color,
min_fsize=min_fsize, max_fsize=max_fsize,
ftype=ftype,
padding_x=padding_x, padding_y=padding_y)
self._attr = attr
self.formatter = formatter
def __name__(self):
return "AttrFace"
def _check_own_node(self):
if not self.node:
raise Exception(f'An associated **node** must be provided to compute **content**.')
[docs]
def get_content(self):
content = str(getattr(self.node, self._attr, None)
or self.node.props.get(self._attr))
self._content = self.formatter % content if self.formatter else content
return self._content
[docs]
class CircleFace(Face):
[docs]
def __init__(self, radius, color, name="", tooltip=None,
padding_x=0, padding_y=0):
Face.__init__(self, name=name,
padding_x=padding_x, padding_y=padding_y)
self.radius = radius
self.color = color
# Drawing private properties
self._max_radius = 0
self._center = (0, 0)
self.tooltip = tooltip
def __name__(self):
return "CircleFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
pos = swap_pos(pos)
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, dx, dy = box
zx, zy = self.zoom
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
padding_x, padding_y = self.padding_x / zx, self.padding_y / (zy * r)
max_dy = dy * zy * r
max_diameter = min(dx * zx, max_dy) if dx != None else max_dy
self._max_radius = min(max_diameter / 2, self.radius)
cx = x + self._max_radius / zx - padding_x
if pos == 'branch_top':
cy = y + dy - self._max_radius / (zy * r) # container bottom
elif pos == 'branch_bottom':
cy = y + self._max_radius / (zy * r) # container top
else: # branch_right and aligned
if pos == 'aligned':
self._max_radius = min(dy * zy * r / 2, self.radius)
cx = x + self._max_radius / zx - padding_x # centered
if pos == 'aligned_bottom':
cy = y + dy - self._max_radius / zy
elif pos == 'aligned_top':
cy = y + self._max_radius / zy
else:
cy = y + dy / 2 # centered
self._center = (cx, cy)
self._box = Box(cx, cy,
2 * (self._max_radius / zx - padding_x),
2 * (self._max_radius) / (zy * r) - padding_y)
return self._box
[docs]
def draw(self, drawer):
self._check_own_variables()
style = {'fill': self.color} if self.color else {}
yield draw_circle(self._center, self._max_radius,
self.name, style=style, tooltip=self.tooltip)
[docs]
class RectFace(Face):
[docs]
def __init__(self, width, height, color='gray',
opacity=0.7,
text=None, fgcolor='black', # text color
min_fsize=6, max_fsize=15,
ftype='sans-serif',
tooltip=None,
name="",
padding_x=0, padding_y=0, stroke_color=None, stroke_width=0):
Face.__init__(self, name=name, padding_x=padding_x, padding_y=padding_y)
self.width = width
self.height = height
self.stretch = True
self.color = color
self.opacity = opacity
# Text related
self.text = str(text) if text is not None else None
self.rotate_text = False
self.fgcolor = fgcolor
self.ftype = ftype
self.min_fsize = min_fsize
self.max_fsize = max_fsize
self.stroke_color = stroke_color
self.stroke_width = stroke_width
self.tooltip = tooltip
def __name__(self):
return "RectFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
pos = swap_pos(pos)
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, dx, dy = box
zx, zy = self.zoom
zx = 1 if self.stretch\
and pos.startswith('aligned')\
and drawer.TYPE != 'circ'\
else zx
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
def get_dimensions(max_width, max_height):
if not (max_width or max_height):
return 0, 0
if (type(max_width) in (int, float) and max_width <= 0) or\
(type(max_height) in (int, float) and max_height <= 0):
return 0, 0
width = self.width / zx if self.width is not None else None
height = self.height / zy if self.height is not None else None
if width is None:
return max_width or 0, min(height or float('inf'), max_height)
if height is None:
return min(width, max_width or float('inf')), max_height
hw_ratio = height / width
if max_width and width > max_width:
width = max_width
height = width * hw_ratio
if max_height and height > max_height:
height = max_height
if not self.stretch or drawer.TYPE == 'circ':
width = height / hw_ratio
height /= r # in circular drawer
return width, height
max_dy = dy * r # take into account circular mode
if pos == 'branch_top':
width, height = get_dimensions(dx, max_dy)
box = (x, y + dy - height, width, height) # container bottom
elif pos == 'branch_bottom':
width, height = get_dimensions(dx, max_dy)
box = (x, y, width, height) # container top
elif pos == 'branch_right':
width, height = get_dimensions(dx, max_dy)
box = (x, y + (dy - height) / 2, width, height)
elif pos.startswith('aligned'):
width, height = get_dimensions(None, dy)
# height = min(dy, (self.height - 2 * self.padding_y) / zy)
# width = min(self.width - 2 * self.padding_x) / zx
if pos == 'aligned_bottom':
y = y + dy - height
elif pos == 'aligned_top':
y = y
else:
y = y + (dy - height) / 2
box = (x, y, width, height)
self._box = Box(*box)
return self._box
[docs]
def draw(self, drawer):
self._check_own_variables()
circ_drawer = drawer.TYPE == 'circ'
style = {
'fill': self.color,
'opacity': self.opacity,
'stroke': self.stroke_color,
'stroke-width': self.stroke_width
}
if self.text and circ_drawer:
rect_id = get_random_string(10)
style['id'] = rect_id
yield draw_rect(self._box,
self.name,
style=style,
tooltip=self.tooltip)
if self.text:
x, y, dx, dy = self._box
zx, zy = self.zoom
r = (x or 1e-10) if circ_drawer else 1
if self.rotate_text:
rotation = 90
self.compute_fsize(dy * zy / (len(self.text) * zx) * r,
dx * zx / zy, zx, zy)
text_box = Box(x + (dx - self._fsize / (2 * zx)) / 2,
y + dy / 2,
dx, dy)
else:
rotation = 0
self.compute_fsize(dx / len(self.text), dy, zx, zy)
text_box = Box(x + dx / 2,
y + (dy - self._fsize / (zy * r)) / 2,
dx, dy)
text_style = {
'max_fsize': self._fsize,
'text_anchor': 'middle',
'ftype': f'{self.ftype}, sans-serif', # default sans-serif
}
if circ_drawer:
offset = dx * zx + dy * zy * r / 2
# Turn text upside down on bottom
if y + dy / 2 > 0:
offset += dx * zx + dy * zy * r
text_style['offset'] = offset
yield draw_text(text_box,
self.text,
rotation=rotation,
anchor=('#' + str(rect_id)) if circ_drawer else None,
style=text_style)
[docs]
class ArrowFace(RectFace):
[docs]
def __init__(self, width, height, orientation='right',
color='gray',
stroke_color='gray', stroke_width='1.5px',
tooltip=None,
text=None, fgcolor='black', # text color
min_fsize=6, max_fsize=15,
ftype='sans-serif',
name="",
padding_x=0, padding_y=0):
RectFace.__init__(self, width=width, height=height,
color=color, text=text, fgcolor=fgcolor,
min_fsize=min_fsize, max_fsize=max_fsize, ftype=ftype,
tooltip=tooltip,
name=name, padding_x=padding_x, padding_y=padding_y)
self.orientation = orientation
self.stroke_color = stroke_color
self.stroke_width = stroke_width
def __name__(self):
return "ArrowFace"
@property
def orientation(self):
return self._orientation
@orientation.setter
def orientation(self, value):
if value not in ('right', 'left'):
raise InvalidUsage('Wrong ArrowFace orientation {value}. Set value to "right" or "left"')
else:
self._orientation = value
[docs]
def draw(self, drawer):
self._check_own_variables()
circ_drawer = drawer.TYPE == 'circ'
style = {
'fill': self.color,
'opacity': 0.7,
'stroke': self.stroke_color,
'stroke-width': self.stroke_width,
}
if self.text and circ_drawer:
rect_id = get_random_string(10)
style['id'] = rect_id
x, y, dx, dy = self._box
zx, zy = self.zoom
tip = min(5, dx * zx * 0.9) / zx
yield draw_arrow(self._box,
tip, self.orientation,
self.name,
style=style,
tooltip=self.tooltip)
if self.text:
r = (x or 1e-10) if circ_drawer else 1
if self.rotate_text:
rotation = 90
self.compute_fsize(dy * zy / (len(self.text) * zx) * r,
dx * zx / zy, zx, zy)
text_box = Box(x + (dx - self._fsize / (2 * zx)) / 2,
y + dy / 2,
dx, dy)
else:
rotation = 0
self.compute_fsize(dx / len(self.text), dy, zx, zy)
text_box = Box(x + dx / 2,
y + (dy - self._fsize / (zy * r)) / 2,
dx, dy)
text_style = {
'max_fsize': self._fsize,
'text_anchor': 'middle',
'ftype': f'{self.ftype}, sans-serif', # default sans-serif
'pointer-events': 'none',
}
if circ_drawer:
offset = dx * zx + dy * zy * r / 2
# Turn text upside down on bottom
if y + dy / 2 > 0:
offset += dx * zx + dy * zy * r
text_style['offset'] = offset
yield draw_text(text_box,
self.text,
rotation=rotation,
anchor=('#' + str(rect_id)) if circ_drawer else None,
style=text_style)
# Selected faces
[docs]
class SelectedFace(Face):
[docs]
def __init__(self, name):
self.name = clean_text(name)
self.name = f'selected_results_{self.name}'
def __name__(self):
return "SelectedFace"
[docs]
class SelectedCircleFace(SelectedFace, CircleFace):
[docs]
def __init__(self, name, radius=15,
padding_x=0, padding_y=0):
SelectedFace.__init__(self, name)
CircleFace.__init__(self, radius=radius, color=None,
name=self.name,
padding_x=padding_x, padding_y=padding_y)
def __name__(self):
return "SelectedCircleFace"
[docs]
class SelectedRectFace(SelectedFace, RectFace):
[docs]
def __init__(self, name, width=15, height=15,
text=None,
padding_x=1, padding_y=0):
SelectedFace.__init__(self, name);
RectFace.__init__(self, width=width, height=height, color=None,
name=self.name, text=text,
padding_x=padding_x, padding_y=padding_y)
def __name__(self):
return "SelectedRectFace"
[docs]
class OutlineFace(Face):
[docs]
def __init__(self,
stroke_color=None, stroke_width=None,
color=None, opacity=0.3,
collapsing_height=5, # height in px at which outline becomes a line
padding_x=0, padding_y=0):
Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
self.outline = None
self.collapsing_height = collapsing_height
self.always_drawn = True
def __name__(self):
return "OutlineFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
self.outline = drawer.outline if drawer.outline \
and len(drawer.outline) == 4 else Box(0, 0, 0, 0)
self.zoom = drawer.zoom[0], drawer.zoom[1]
if drawer.TYPE == 'circ':
r, a, dr, da = self.outline
a1, a2 = clip_angles(a, a + da)
self.outline = Box(r, a1, dr, a2 - a1)
return self.get_box()
[docs]
def get_box(self):
if self.outline and len(self.outline) == 4:
x, y, dx, dy = self.outline
return Box(x, y, dx, dy)
return Box(0, 0, 0, 0)
[docs]
def fits(self):
return True
[docs]
def draw(self, drawer):
nodestyle = self.node.sm_style
style = {
'stroke': nodestyle["outline_line_color"],
'stroke-width': nodestyle["outline_line_width"],
'fill': nodestyle["outline_color"],
'fill-opacity': nodestyle["outline_opacity"],
}
x, y, dx, dy = self.outline
zx, zy = self.zoom
circ_drawer = drawer.TYPE == 'circ'
r = (x or 1e-10) if circ_drawer else 1
if dy * zy * r < self.collapsing_height:
# Convert to line if height less than one pixel
p1 = (x, y + dy / 2)
p2 = (x + dx, y + dy / 2)
if circ_drawer:
p1 = cartesian(p1)
p2 = cartesian(p2)
yield draw_line(p1, p2, style=style)
else:
yield draw_outline(self.outline, style=style)
[docs]
class AlignLinkFace(Face):
[docs]
def __init__(self,
stroke_color='gray', stroke_width=0.5,
line_type=1, opacity=0.8):
"""Line types: 0 solid, 1 dotted, 2 dashed"""
Face.__init__(self, padding_x=0, padding_y=0)
self.line = None
self.stroke_color = stroke_color
self.stroke_width = stroke_width
self.type = line_type;
self.opacity = opacity
self.always_drawn = True
def __name__(self):
return "AlignLinkFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if drawer.NPANELS > 1 and drawer.viewport and pos == 'branch_right':
x, y = point
dx, dy = size
p1 = (x + bdx + dx_before, y + dy/2)
if drawer.TYPE == 'rect':
p2 = (drawer.viewport.x + drawer.viewport.dx, y + dy/2)
else:
aligned = sorted(drawer.tree_style.aligned_grid_dxs.items())
# p2 = (drawer.node_size(drawer.tree)[0], y + dy/2)
if not len(aligned):
return Box(0, 0, 0, 0)
p2 = (aligned[0][1] - bdx, y + dy/2)
if p1[0] > p2[0]:
return Box(0, 0, 0, 0)
p1, p2 = cartesian(p1), cartesian(p2)
self.line = (p1, p2)
return Box(0, 0, 0, 0) # Should not take space
[docs]
def get_box(self):
return Box(0, 0, 0, 0) # Should not take space
[docs]
def fits(self):
return True
[docs]
def draw(self, drawer):
if drawer.NPANELS < 2:
return None
style = {
'type': self.type,
'stroke': self.stroke_color,
'stroke-width': self.stroke_width,
'opacity': self.opacity,
}
if drawer.panel == 0 and drawer.viewport and\
(self.node.is_leaf or self.node.is_collapsed)\
and self.line:
p1, p2 = self.line
yield draw_line(p1, p2, 'align-link', style=style)
[docs]
class SeqFace(Face):
[docs]
def __init__(self, seq, seqtype='aa', poswidth=15,
draw_text=True, max_fsize=15, ftype='sans-serif',
padding_x=0, padding_y=0):
Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
self.seq = seq
self.seqtype = seqtype
self.colors = _aacolors if self.seqtype == 'aa' else _ntcolors
self.poswidth = poswidth # width of each nucleotide/aa
# Text
self.draw_text = draw_text
self.ftype = ftype
self.max_fsize = max_fsize
self._fsize = None
def __name__(self):
return "SeqFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if pos not in ('branch_right', 'aligned'):
raise InvalidUsage(f'Position {pos} not allowed for SeqFace')
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, _, dy = box
zx, zy = self.zoom
dx = self.poswidth * len(self.seq) / zx
if self.draw_text:
self.compute_fsize(self.poswidth / zx, dy, zx, zy)
self._box = Box(x, y, dx, dy)
return self._box
[docs]
def draw(self, drawer):
x0, y, _, dy = self._box
zx, zy = self.zoom
dx = self.poswidth / zx
# Send sequences as a whole to be rendered by PIXIjs
if self.draw_text:
aa_type = "text"
else:
aa_type = "notext"
yield [ f'pixi-aa_{aa_type}', Box(x0, y, dx * len(self.seq), dy), self.seq ]
# Rende text if necessary
# if self.draw_text:
# text_style = {
# 'max_fsize': self._fsize,
# 'text_anchor': 'middle',
# 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
# }
# for idx, pos in enumerate(self.seq):
# x = x0 + idx * dx
# r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
# # Draw rect
# if pos != '-':
# text_box = Box(x + dx / 2,
# y + (dy - self._fsize / (zy * r)) / 2,
# dx, dy)
# yield draw_text(text_box,
# pos,
# style=text_style)
[docs]
class SeqMotifFace(Face):
[docs]
def __init__(self, seq=None, motifs=None, seqtype='aa',
gap_format='line', seq_format='[]',
width=None, height=None, # max height
fgcolor='black', bgcolor='#bcc3d0', gapcolor='gray',
gap_linewidth=0.2,
max_fsize=12, ftype='sans-serif',
padding_x=0, padding_y=0):
if not motifs and not seq:
raise ValueError(
"At least one argument (seq or motifs) should be provided.")
Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
self.seq = seq or '-' * max([m[1] for m in motifs])
self.seqtype = seqtype
self.autoformat = True # block if 1px contains > 1 tile
self.motifs = motifs
self.overlaping_motif_opacity = 0.5
self.seq_format = seq_format
self.gap_format = gap_format
self.gap_linewidth = gap_linewidth
self.compress_gaps = False
self.poswidth = 0.5
self.w_scale = 1
self.width = width # sum of all regions' width if not provided
self.height = height # dynamically computed if not provided
self.fg = '#000'
self.bg = _aacolors if self.seqtype == 'aa' else _ntcolors
self.fgcolor = fgcolor
self.bgcolor = bgcolor
self.gapcolor = gapcolor
self.triangles = {'^': 'top', '>': 'right', 'v': 'bottom', '<': 'left'}
# Text
self.ftype = ftype
self._min_fsize = 8
self.max_fsize = max_fsize
self._fsize = None
self.regions = []
self.build_regions()
def __name__(self):
return "SeqMotifFace"
[docs]
def build_regions(self):
"""Build and sort sequence regions: seq representation and motifs"""
seq = self.seq
motifs = deepcopy(self.motifs)
# if only sequence is provided, build regions out of gap spaces
if not motifs:
if self.seq_format == "seq":
motifs = [[0, len(seq), "seq",
15, self.height, None, None, None]]
else:
motifs = []
pos = 0
for reg in re.split('([^-]+)', seq):
if reg:
if not reg.startswith("-"):
motifs.append([pos, pos+len(reg)-1,
self.seq_format,
self.poswidth, self.height,
self.fgcolor, self.bgcolor, None])
pos += len(reg)
motifs.sort()
# complete missing regions
current_seq_pos = 0
for index, mf in enumerate(motifs):
start, end, typ, w, h, fg, bg, name = mf
if start > current_seq_pos:
pos = current_seq_pos
for reg in re.split('([^-]+)', seq[current_seq_pos:start]):
if reg:
if reg.startswith("-") and self.seq_format != "seq":
self.regions.append([pos, pos+len(reg)-1,
"gap_"+self.gap_format, self.poswidth, self.height,
self.gapcolor, None, None])
else:
self.regions.append([pos, pos+len(reg)-1,
self.seq_format, self.poswidth, self.height,
self.fgcolor, self.bgcolor, None])
pos += len(reg)
current_seq_pos = start
self.regions.append(mf)
current_seq_pos = end + 1
if len(seq) > current_seq_pos:
pos = current_seq_pos
for reg in re.split('([^-]+)', seq[current_seq_pos:]):
if reg:
if reg.startswith("-") and self.seq_format != "seq":
self.regions.append([pos, pos+len(reg)-1,
"gap_"+self.gap_format,
self.poswidth, 1,
self.gapcolor, None, None])
else:
self.regions.append([pos, pos+len(reg)-1,
self.seq_format,
self.poswidth, self.height,
self.fgcolor, self.bgcolor, None])
pos += len(reg)
# Compute total width and
# Detect overlapping, reducing opacity in overlapping elements
total_width = 0
prev_end = -1
for idx, (start, end, shape, w, *_) in enumerate(self.regions):
overlapping = abs(min(start - 1 - prev_end, 0))
w = self.poswidth if shape.startswith("gap_") and self.compress_gaps else w
total_width += (w or self.poswidth) * (end + 1 - start - overlapping)
prev_end = end
opacity = self.overlaping_motif_opacity if overlapping else 1
self.regions[idx].append(opacity)
if overlapping:
self.regions[idx - 1][-1] = opacity
if self.width:
self.w_scale = self.width / total_width
else:
self.width = total_width
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if pos != 'branch_right' and not pos.startswith('aligned'):
raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace')
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, _, dy = box
zx, zy = self.zoom
self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
self._box = Box(x, y, self.width / zx, dy)
return self._box
[docs]
def fits(self):
return True
[docs]
def draw(self, drawer):
# Only leaf/collapsed branch_right or aligned
x0, y, _, dy = self._box
zx, zy = self.zoom
if self.viewport and len(self.seq):
vx0, vx1 = self.viewport
too_small = ((vx1 - vx0) * zx) / (len(self.seq) / zx) < 3
if self.seq_format in [ "seq", "compactseq" ] and too_small:
self.seq_format = "[]"
self.regions = []
self.build_regions()
if self.seq_format == "[]" and not too_small:
self.seq_format = "seq"
self.regions = []
self.build_regions()
x = x0
prev_end = -1
if self.gap_format in ["line", "-"]:
p1 = (x0, y + dy / 2)
p2 = (x0 + self.width, y + dy / 2)
if drawer.TYPE == 'circ':
p1 = cartesian(p1)
p2 = cartesian(p2)
yield draw_line(p1, p2, style={'stroke-width': self.gap_linewidth,
'stroke': self.gapcolor})
for item in self.regions:
if len(item) == 9:
start, end, shape, posw, h, fg, bg, text, opacity = item
else:
continue
# if not self.in_aligned_viewport((start / zx, end / zx)):
# continue
posw = (posw or self.poswidth) * self.w_scale
w = posw * (end + 1 - start)
style = { 'fill': bg, 'opacity': opacity }
# Overlapping
overlapping = abs(min(start - 1 - prev_end, 0))
if overlapping:
x -= posw * overlapping
prev_end = end
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
default_h = dy * zy * r
h = min([h or default_h, self.height or default_h, default_h]) / zy
box = Box(x, y + (dy - h / r) / 2, w, h / r)
if shape.startswith("gap_"):
if self.compress_gaps:
w = posw
x += w
continue
# Line
if shape in ['line', '-']:
p1 = (x, y + dy / 2)
p2 = (x + w, y + dy / 2)
if drawer.TYPE == 'circ':
p1 = cartesian(p1)
p2 = cartesian(p2)
yield draw_line(p1, p2, style={'stroke-width': 0.5, 'stroke': fg})
# Rectangle
elif shape == '[]':
yield [ "pixi-block", box ]
elif shape == '()':
style['rounded'] = 1;
yield draw_rect(box, '', style=style)
# Rhombus
elif shape == '<>':
yield draw_rhombus(box, style=style)
# Triangle
elif shape in self.triangles.keys():
box = Box(x, y + (dy - h / r) / 2, w, h / r)
yield draw_triangle(box, self.triangles[shape], style=style)
# Circle/ellipse
elif shape == 'o':
center = (x + w / 2, y + dy / 2)
rx = w * zx / 2
ry = h * zy / 2
if rx == ry:
yield draw_circle(center, rx, style=style)
else:
yield draw_ellipse(center, rx, ry, style=style)
# Sequence and compact sequence
elif shape in ['seq', 'compactseq']:
seq = self.seq[start : end + 1]
if self.viewport:
sx, sy, sw, sh = box
sposw = sw / len(seq)
viewport_start = self.viewport[0] - self.viewport_margin / zx
viewport_end = self.viewport[1] + self.viewport_margin / zx
sm_x = max(viewport_start - sx, 0)
sm_start = round(sm_x / sposw)
sm_end = len(seq) - round(max(sx + sw - viewport_end, 0) / sposw)
seq = seq[sm_start:sm_end]
sm_box = (sm_x, sy, sposw * len(seq), sh)
if shape == 'compactseq' or posw * zx < self._min_fsize:
aa_type = "notext"
else:
aa_type = "text"
yield [ f'pixi-aa_{aa_type}', sm_box, seq ]
# Text on top of shape
if text:
try:
ftype, fsize, color, text = text.split("|")
fsize = int(fsize)
except:
ftype, fsize, color = self.ftype, self.max_fsize, (fg or self.fcolor)
self.compute_fsize(w / len(text), h, zx, zy, fsize)
text_box = Box(x + w / 2,
y + (dy - self._fsize / (zy * r)) / 2,
self._fsize / (zx * CHAR_HEIGHT),
self._fsize / zy)
text_style = {
'max_fsize': self._fsize,
'text_anchor': 'middle',
'ftype': f'{ftype}, sans-serif',
'fill': color,
}
yield draw_text(text_box, text, style=text_style)
# Update x to draw consecutive motifs
x += w
[docs]
class AlignmentFace(Face):
[docs]
def __init__(self, seq, seqtype='aa',
gap_format='line', seq_format='[]',
width=None, height=None, # max height
fgcolor='black', bgcolor='#bcc3d0', gapcolor='gray',
gap_linewidth=0.2,
max_fsize=12, ftype='sans-serif',
padding_x=0, padding_y=0):
Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
self.seq = seq
self.seqlength = len(self.seq)
self.seqtype = seqtype
self.autoformat = True # block if 1px contains > 1 tile
self.seq_format = seq_format
self.gap_format = gap_format
self.gap_linewidth = gap_linewidth
self.compress_gaps = False
self.poswidth = 5
self.w_scale = 1
self.width = width # sum of all regions' width if not provided
self.height = height # dynamically computed if not provided
total_width = self.seqlength * self.poswidth
if self.width:
self.w_scale = self.width / total_width
else:
self.width = total_width
self.bg = _aacolors if self.seqtype == 'aa' else _ntcolors
# self.fgcolor = fgcolor
# self.bgcolor = bgcolor
self.gapcolor = gapcolor
# Text
self.ftype = ftype
self._min_fsize = 8
self.max_fsize = max_fsize
self._fsize = None
self.blocks = []
self.build_blocks()
def __name__(self):
return "AlignmentFace"
[docs]
def get_seq(self, start, end):
"""Retrieves sequence given start, end"""
return self.seq[start:end]
[docs]
def build_blocks(self):
pos = 0
for reg in re.split('([^-]+)', self.seq):
if reg:
if not reg.startswith("-"):
self.blocks.append([pos, pos + len(reg) - 1])
pos += len(reg)
self.blocks.sort()
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if pos != 'branch_right' and not pos.startswith('aligned'):
raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace')
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, _, dy = box
zx, zy = self.zoom
zx = 1 if drawer.TYPE != 'circ' else zx
# zx = drawer.zoom[0]
# self.zoom = (zx, zy)
if drawer.TYPE == "circ":
self.viewport = (0, drawer.viewport.dx)
else:
self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
self._box = Box(x, y, self.width / zx, dy)
return self._box
[docs]
def draw(self, drawer):
def get_height(x, y):
r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
default_h = dy * zy * r
h = min([self.height or default_h, default_h]) / zy
# h /= r
return y + (dy - h) / 2, h
# Only leaf/collapsed branch_right or aligned
x0, y, dx, dy = self._box
zx, zy = self.zoom
zx = drawer.zoom[0] if drawer.TYPE == 'circ' else zx
if self.gap_format in ["line", "-"]:
p1 = (x0, y + dy / 2)
p2 = (x0 + self.width, y + dy / 2)
if drawer.TYPE == 'circ':
p1 = cartesian(p1)
p2 = cartesian(p2)
yield draw_line(p1, p2, style={'stroke-width': self.gap_linewidth,
'stroke': self.gapcolor})
vx0, vx1 = self.viewport
too_small = (self.width * zx) / (self.seqlength) < 1
posw = self.poswidth * self.w_scale
viewport_start = vx0 - self.viewport_margin / zx
viewport_end = vx1 + self.viewport_margin / zx
sm_x = max(viewport_start - x0, 0)
sm_start = round(sm_x / posw)
w = self.seqlength * posw
sm_x0 = x0 if drawer.TYPE == "rect" else 0
sm_end = self.seqlength - round(max(sm_x0 + w - viewport_end, 0) / posw)
if too_small or self.seq_format == "[]":
for start, end in self.blocks:
if end >= sm_start and start <= sm_end:
bstart = max(sm_start, start)
bend = min(sm_end, end)
bx = x0 + bstart * posw
by, bh = get_height(bx, y)
box = Box(bx, by, (bend + 1 - bstart) * posw, bh)
yield [ "pixi-block", box ]
else:
seq = self.get_seq(sm_start, sm_end)
sm_x = sm_x if drawer.TYPE == 'rect' else x0
y, h = get_height(sm_x, y)
sm_box = Box(sm_x0 + sm_x, y, posw * len(seq), h)
if self.seq_format == 'compactseq' or posw * zx < self._min_fsize:
aa_type = "notext"
else:
aa_type = "text"
yield [ f'pixi-aa_{aa_type}', sm_box, seq ]
[docs]
class ScaleFace(Face):
[docs]
def __init__(self, name='', width=None, color='black',
scale_range=(0, 0), tick_width=80, line_width=1,
formatter='%.0f',
min_fsize=6, max_fsize=12, ftype='sans-serif',
padding_x=0, padding_y=0):
Face.__init__(self, name=name,
padding_x=padding_x, padding_y=padding_y)
self.width = width
self.height = None
self.range = scale_range
self.color = color
self.min_fsize = min_fsize
self.max_fsize = max_fsize
self._fsize = max_fsize
self.ftype = ftype
self.formatter = formatter
self.tick_width = tick_width
self.line_width = line_width
self.vt_line_height = 10
def __name__(self):
return "ScaleFace"
[docs]
def compute_bounding_box(self,
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before):
if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
pos = swap_pos(pos)
box = super().compute_bounding_box(
drawer,
point, size,
dx_to_closest_child,
bdx, bdy,
bdy0, bdy1,
pos, row,
n_row, n_col,
dx_before, dy_before)
x, y, _, dy = box
zx, zy = self.zoom
self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
self.height = (self.line_width + 10 + self.max_fsize) / zy
height = min(dy, self.height)
if pos == "aligned_bottom":
y = y + dy - height
self._box = Box(x, y, self.width / zx, height)
return self._box
[docs]
def draw(self, drawer):
x0, y, _, dy = self._box
zx, zy = self.zoom
p1 = (x0, y + dy - 5 / zy)
p2 = (x0 + self.width, y + dy - self.vt_line_height / (2 * zy))
if drawer.TYPE == 'circ':
p1 = cartesian(p1)
p2 = cartesian(p2)
yield draw_line(p1, p2, style={'stroke-width': self.line_width,
'stroke': self.color})
nticks = round((self.width * zx) / self.tick_width)
dx = self.width / nticks
range_factor = (self.range[1] - self.range[0]) / self.width
if self.viewport:
sm_start = round(max(self.viewport[0] - self.viewport_margin - x0, 0) / dx)
sm_end = nticks - round(max(x0 + self.width - (self.viewport[1] +
self.viewport_margin), 0) / dx)
else:
sm_start, sm_end = 0, nticks
for i in range(sm_start, sm_end + 1):
x = x0 + i * dx
number = range_factor * i * dx
if number == 0:
text = "0"
else:
text = self.formatter % number if self.formatter else str(number)
text = text.rstrip('0').rstrip('.') if '.' in text else text
self.compute_fsize(self.tick_width / len(text), dy, zx, zy)
text_style = {
'max_fsize': self._fsize,
'text_anchor': 'middle',
'ftype': f'{self.ftype}, sans-serif', # default sans-serif
}
text_box = Box(x,
y,
# y + (dy - self._fsize / (zy * r)) / 2,
dx, dy)
yield draw_text(text_box, text, style=text_style)
p1 = (x, y + dy - self.vt_line_height / zy)
p2 = (x, y + dy)
yield draw_line(p1, p2, style={'stroke-width': self.line_width,
'stroke': self.color})
[docs]
class PieChartFace(CircleFace):
[docs]
def __init__(self, radius, data, name="",
padding_x=0, padding_y=0, tooltip=None):
super().__init__(self, name=name, color=None,
padding_x=padding_x, padding_y=padding_y, tooltip=tooltip)
self.radius = radius
# Drawing private properties
self._max_radius = 0
self._center = (0, 0)
# data = [ [name, value, color, tooltip], ... ]
# self.data = [ (name, value, color, tooltip, a, da) ]
self.data = []
self.compute_pie(list(data))
def __name__(self):
return "PieChartFace"
[docs]
def compute_pie(self, data):
total_value = sum(d[1] for d in data)
a = 0
for name, value, color, tooltip in data:
da = (value / total_value) * 2 * pi
self.data.append((name, value, color, tooltip, a, da))
a += da
assert a >= 2 * pi - 1e-5 and a <= 2 * pi + 1e-5, "Incorrect pie"
[docs]
def draw(self, drawer):
# Draw circle if only one datum
if len(self.data) == 1:
self.color = self.data[0][2]
yield from CircleFace.draw(self, drawer)
else:
for name, value, color, tooltip, a, da in self.data:
style = { 'fill': color }
yield draw_slice(self._center, self._max_radius, a, da,
"", style=style, tooltip=tooltip)
[docs]
class HTMLFace(RectFace):
[docs]
def __init__(self, html, width, height, name="", padding_x=0, padding_y=0):
RectFace.__init__(self, width=width, height=height,
name=name, padding_x=padding_x, padding_y=padding_y)
self.content = html
def __name__(self):
return "HTMLFace"
[docs]
def draw(self, drawer):
yield draw_html(self._box, self.content)
[docs]
class ImgFace(RectFace):
[docs]
def __init__(self, img_path, width, height, name="", padding_x=0, padding_y=0):
RectFace.__init__(self, width=width, height=height,
name=name, padding_x=padding_x, padding_y=padding_y)
with open(img_path, "rb") as handle:
img = base64.b64encode(handle.read()).decode("utf-8")
extension = pathlib.Path(img_path).suffix[1:]
if extension not in ALLOWED_IMG_EXTENSIONS:
print("The image does not have an allowed format: " +
extension + " not in " + str(ALLOWED_IMG_EXTENSIONS))
self.content = f'data:image/{extension};base64,{img}'
self.stretch = False
def __name__(self):
return "ImgFace"
[docs]
def draw(self, drawer):
yield draw_img(self._box, self.content)
[docs]
class LegendFace(Face):
[docs]
def __init__(self,
colormap,
title,
min_fsize=6, max_fsize=15, ftype='sans-serif',
padding_x=0, padding_y=0):
Face.__init__(self, name=title,
padding_x=padding_x, padding_y=padding_y)
self._content = True
self.title = title
self.min_fsize = min_fsize
self.max_fsize = max_fsize
self._fsize = max_fsize
self.ftype = ftype
def __name__(self):
return "LegendFace"
[docs]
def draw(self, drawer):
self._check_own_variables()
style = {'fill': self.color, 'opacity': self.opacity}
x, y, dx, dy = self._box
zx, zy = self.zoom
entry_h = min(15 / zy, dy / (len(self.colormap.keys()) + 2))
title_box = Box(x, y + 5, dx, entry_h)
text_style = {
'max_fsize': self.compute_fsize(title_box.dx, title_box.dy, zx, zy),
'text_anchor': 'middle',
'ftype': f'{self.ftype}, sans-serif', # default sans-serif
}
yield draw_text(title_box,
self.title,
style=text_style)
entry_y = y + 2 * entry_h
for value, color in self.colormap.items():
text_box = Box(x, entry_y, dx, entry_h)
yield draw_text(text_box,
value,
style=text_style)
ty += entry_h
[docs]
class StackedBarFace(RectFace):
"""Face to show a series of stacked bars."""
[docs]
def __init__(self, width, height, data=None, name='', opacity=0.7,
min_fsize=6, max_fsize=15, ftype='sans-serif',
padding_x=0, padding_y=0, tooltip=None):
"""Initialize the face.
:param data: List of tuples, like [(whatever, value, color), ...].
"""
super().__init__(width=width, height=height, name=name, color=None,
min_fsize=min_fsize, max_fsize=max_fsize,
padding_x=padding_x, padding_y=padding_y, tooltip=tooltip)
self.data = data
def __name__(self):
return "StackedBarFace"
[docs]
def draw(self, drawer):
x0, y0, dx0, dy0 = self._box
total = sum(d[1] for d in self.data)
scale_factor = dx0 / (total or 1) # the "or 1" prevents dividing by 0
x = x0
for _, value, color, *_ in self.data:
dx = scale_factor * value
box = Box(x, y0, dx, dy0)
yield draw_rect(box, self.name, style={'fill': color},
tooltip=self.tooltip)
x += dx