Coverage for /home/deng/Projects/ete4/hackathon/ete4/ete4/smartview/renderer/faces.py: 14%
896 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-08-07 10:27 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2024-08-07 10:27 +0200
1import base64
2from collections.abc import Iterable
3import pathlib
4import re
5from math import pi
7from ..utils import InvalidUsage, get_random_string
8from .draw_helpers import *
9from copy import deepcopy
11CHAR_HEIGHT = 1.4 # char's height to width ratio
13ALLOWED_IMG_EXTENSIONS = [ "png", "svg", "jpeg" ]
15_aacolors = {
16 'A':"#C8C8C8" ,
17 'R':"#145AFF" ,
18 'N':"#00DCDC" ,
19 'D':"#E60A0A" ,
20 'C':"#E6E600" ,
21 'Q':"#00DCDC" ,
22 'E':"#E60A0A" ,
23 'G':"#EBEBEB" ,
24 'H':"#8282D2" ,
25 'I':"#0F820F" ,
26 'L':"#0F820F" ,
27 'K':"#145AFF" ,
28 'M':"#E6E600" ,
29 'F':"#3232AA" ,
30 'P':"#DC9682" ,
31 'S':"#FA9600" ,
32 'T':"#FA9600" ,
33 'W':"#B45AB4" ,
34 'Y':"#3232AA" ,
35 'V':"#0F820F" ,
36 'B':"#FF69B4" ,
37 'Z':"#FF69B4" ,
38 'X':"#BEA06E",
39 '.':"#FFFFFF",
40 '-':"#FFFFFF",
41 }
43_ntcolors = {
44 'A':'#A0A0FF',
45 'G':'#FF7070',
46 'I':'#80FFFF',
47 'C':'#FF8C4B',
48 'T':'#A0FFA0',
49 'U':'#FF8080',
50 '.':"#FFFFFF",
51 '-':"#FFFFFF",
52 ' ':"#FFFFFF"
53 }
55__all__ = [
56 'Face', 'TextFace', 'AttrFace', 'CircleFace', 'RectFace',
57 'ArrowFace', 'SelectedFace', 'SelectedCircleFace',
58 'SelectedRectFace', 'OutlineFace', 'AlignLinkFace', 'SeqFace',
59 'SeqMotifFace', 'AlignmentFace', 'ScaleFace', 'PieChartFace',
60 'HTMLFace', 'ImgFace', 'LegendFace', 'StackedBarFace']
63def clean_text(text):
64 return re.sub(r'[^A-Za-z0-9_-]', '', text)
67def swap_pos(pos):
68 if pos == 'branch_top':
69 return 'branch_bottom'
70 elif pos == 'branch_bottom':
71 return 'branch_top'
72 else:
73 return pos
76def stringify(content):
77 if type(content) in (str, float, int):
78 return str(content)
79 if isinstance(content, Iterable):
80 return ",".join(map(str, content))
81 return str(content)
84class Face:
85 """
86 Base class for faces.
88 Ete uses "faces" to show some piece of information from
89 a node in a tree (as text or graphics of many kinds).
90 """
92 def __init__(self, name="", padding_x=0, padding_y=0):
93 self.node = None
94 self.name = name
95 self._content = "Empty"
96 self._box = None
97 self.padding_x = padding_x
98 self.padding_y = padding_y
100 self.always_drawn = False # Use carefully to avoid overheading...
102 self.zoom = (0, 0)
103 self.stretch = False # Stretch width independently of height
104 self.viewport = None # Aligned panel viewport (x1, x2)
105 self.viewport_margin = 100
107 def __name__(self):
108 return "Face"
110 def in_aligned_viewport(self, segment):
111 if self.viewport:
112 return intersects_segment(self.viewport, segment)
113 return True
115 def get_content(self):
116 return self._content
118 def get_box(self):
119 self._check_own_variables()
120 return self._box
122 def compute_fsize(self, dx, dy, zx, zy, max_fsize=None):
123 self._fsize = min([dx * zx * CHAR_HEIGHT, abs(dy * zy), max_fsize or self.max_fsize])
125 def compute_bounding_box(self,
126 drawer,
127 point, size,
128 dx_to_closest_child,
129 bdx, bdy,
130 bdy0, bdy1,
131 pos, row,
132 n_row, n_col,
133 dx_before, dy_before):
135 self._check_own_content()
136 x, y = point
137 dx, dy = size
139 zx, zy, za = drawer.zoom
140 if pos.startswith("aligned"):
141 zx = za
142 self.zoom = (zx, zy)
144 if pos == 'branch_top': # above the branch
145 avail_dx = dx / n_col
146 avail_dy = bdy / n_row
147 x = x + dx_before
148 y = y + bdy - avail_dy - dy_before
150 elif pos == 'branch_bottom': # below the branch
151 avail_dx = dx / n_col
152 avail_dy = (dy - bdy) / n_row
153 x = x + dx_before
154 y = y + bdy + dy_before
156 elif pos == 'branch_right': # right of node
157 avail_dx = dx_to_closest_child / n_col\
158 if not (self.node.is_leaf or self.node.is_collapsed)\
159 else None
160 avail_dy = min([bdy, dy - bdy, bdy - bdy0, bdy1 - bdy]) * 2 / n_row
161 x = x + bdx + dx_before
162 y = y + bdy + (row - n_row / 2) * avail_dy
164 elif pos.startswith('aligned'): # right of tree
165 avail_dx = None # should be overriden
166 avail_dy = dy / n_row
167 aligned_x = drawer.node_size(drawer.tree)[0]\
168 if drawer.panel == 0 else drawer.xmin
169 x = aligned_x + dx_before
171 if pos == 'aligned_bottom':
172 y = y + dy - avail_dy - dy_before
173 elif pos == 'aligned_top':
174 y = y + dy_before
175 else:
176 y = y + dy / 2 + (row - n_row / 2) * avail_dy
178 else:
179 raise InvalidUsage(f'unkown position {pos}')
181 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
182 padding_x = self.padding_x / zx
183 padding_y = self.padding_y / (zy * r)
185 self._box = Box(
186 x + padding_x,
187 y + padding_y,
188 # avail_dx may not be initialized for branch_right and aligned
189 max(avail_dx - 2 * padding_x, 0) if avail_dx else None,
190 max(avail_dy - 2 * padding_y, 0))
192 return self._box
194 def fits(self):
195 """
196 Return True if Face fits in computed box.
197 Method overriden by inheriting classes.
198 """
199 return True
201 def _check_own_content(self):
202 if not self._content:
203 raise Exception(f'**Content** has not been computed yet.')
205 def _check_own_variables(self):
206 if not self._box:
207 raise Exception(f'**Box** has not been computed yet.\
208 \nPlease run `compute_bounding_box()` first')
209 self._check_own_content()
210 return
213class TextFace(Face):
215 def __init__(self, text, name='', color='black',
216 min_fsize=6, max_fsize=15, ftype='sans-serif',
217 padding_x=0, padding_y=0, width=None, rotation=None):
218 # NOTE: if width is passed as an argument, then it is not
219 # computed from fit_fontsize() (this is part of a temporary
220 # hack to make LayoutBarPlot work).
222 # FIXME: The rotation is not being taken into account when
223 # computing the bounding box.
225 Face.__init__(self, name=name,
226 padding_x=padding_x, padding_y=padding_y)
228 self._content = stringify(text)
229 self.color = color
230 self.min_fsize = min_fsize
231 self.max_fsize = max_fsize
232 self._fsize = max_fsize
233 self.rotation = rotation
234 self.width = width
235 self.ftype = ftype
237 def __name__(self):
238 return "TextFace"
240 def compute_bounding_box(self,
241 drawer,
242 point, size,
243 dx_to_closest_child,
244 bdx, bdy,
245 bdy0, bdy1,
246 pos, row,
247 n_row, n_col,
248 dx_before, dy_before):
250 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
251 pos = swap_pos(pos)
253 box = super().compute_bounding_box(
254 drawer,
255 point, size,
256 dx_to_closest_child,
257 bdx, bdy,
258 bdy0, bdy1,
259 pos, row,
260 n_row, n_col,
261 dx_before, dy_before)
263 zx, zy = self.zoom
265 x, y , dx, dy = box
266 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
268 def fit_fontsize(text, dx, dy):
269 dchar = dx / len(text) if dx != None else float('inf')
270 self.compute_fsize(dchar, dy, zx, zy)
271 dxchar = self._fsize / (zx * CHAR_HEIGHT)
272 dychar = self._fsize / (zy * r)
273 return dxchar * len(text), dychar
275 # FIXME: Temporary hack to make the headers of LayoutBarPlot work.
276 if self.width:
277 width = self.width
278 _, height = fit_fontsize(self._content, dx, dy * r)
279 else:
280 width, height = fit_fontsize(self._content, dx, dy * r)
283 if pos == 'branch_top':
284 box = (x, y + dy - height, width, height) # container bottom
286 elif pos == 'branch_bottom':
287 box = (x, y, width, height) # container top
289 elif pos == 'aligned_bottom':
290 box = (x, y + dy - height, width, height)
292 elif pos == 'aligned_top':
293 box = (x, y, width, height)
295 else: # branch_right and aligned
296 box = (x, y + (dy - height) / 2, width, height)
298 self._box = Box(*box)
299 return self._box
301 def fits(self):
302 return self._content != "None" and self._fsize >= self.min_fsize
304 def draw(self, drawer):
305 self._check_own_variables()
306 style = {
307 'fill': self.color,
308 'max_fsize': self._fsize,
309 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
310 }
311 yield draw_text(self._box,
312 self._content, self.name, rotation=self.rotation, style=style)
315class AttrFace(TextFace):
317 def __init__(self, attr,
318 formatter=None,
319 name=None,
320 color="black",
321 min_fsize=6, max_fsize=15,
322 ftype="sans-serif",
323 padding_x=0, padding_y=0):
325 TextFace.__init__(self, text="",
326 name=name, color=color,
327 min_fsize=min_fsize, max_fsize=max_fsize,
328 ftype=ftype,
329 padding_x=padding_x, padding_y=padding_y)
331 self._attr = attr
332 self.formatter = formatter
334 def __name__(self):
335 return "AttrFace"
337 def _check_own_node(self):
338 if not self.node:
339 raise Exception(f'An associated **node** must be provided to compute **content**.')
341 def get_content(self):
342 content = str(getattr(self.node, self._attr, None)
343 or self.node.props.get(self._attr))
344 self._content = self.formatter % content if self.formatter else content
345 return self._content
348class CircleFace(Face):
350 def __init__(self, radius, color, name="", tooltip=None,
351 padding_x=0, padding_y=0):
353 Face.__init__(self, name=name,
354 padding_x=padding_x, padding_y=padding_y)
356 self.radius = radius
357 self.color = color
358 # Drawing private properties
359 self._max_radius = 0
360 self._center = (0, 0)
362 self.tooltip = tooltip
364 def __name__(self):
365 return "CircleFace"
367 def compute_bounding_box(self,
368 drawer,
369 point, size,
370 dx_to_closest_child,
371 bdx, bdy,
372 bdy0, bdy1,
373 pos, row,
374 n_row, n_col,
375 dx_before, dy_before):
377 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
378 pos = swap_pos(pos)
380 box = super().compute_bounding_box(
381 drawer,
382 point, size,
383 dx_to_closest_child,
384 bdx, bdy,
385 bdy0, bdy1,
386 pos, row,
387 n_row, n_col,
388 dx_before, dy_before)
390 x, y, dx, dy = box
391 zx, zy = self.zoom
393 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
394 padding_x, padding_y = self.padding_x / zx, self.padding_y / (zy * r)
396 max_dy = dy * zy * r
397 max_diameter = min(dx * zx, max_dy) if dx != None else max_dy
398 self._max_radius = min(max_diameter / 2, self.radius)
400 cx = x + self._max_radius / zx - padding_x
402 if pos == 'branch_top':
403 cy = y + dy - self._max_radius / (zy * r) # container bottom
405 elif pos == 'branch_bottom':
406 cy = y + self._max_radius / (zy * r) # container top
408 else: # branch_right and aligned
409 if pos == 'aligned':
410 self._max_radius = min(dy * zy * r / 2, self.radius)
412 cx = x + self._max_radius / zx - padding_x # centered
414 if pos == 'aligned_bottom':
415 cy = y + dy - self._max_radius / zy
417 elif pos == 'aligned_top':
418 cy = y + self._max_radius / zy
420 else:
421 cy = y + dy / 2 # centered
423 self._center = (cx, cy)
424 self._box = Box(cx, cy,
425 2 * (self._max_radius / zx - padding_x),
426 2 * (self._max_radius) / (zy * r) - padding_y)
428 return self._box
430 def draw(self, drawer):
431 self._check_own_variables()
432 style = {'fill': self.color} if self.color else {}
433 yield draw_circle(self._center, self._max_radius,
434 self.name, style=style, tooltip=self.tooltip)
437class RectFace(Face):
438 def __init__(self, width, height, color='gray',
439 opacity=0.7,
440 text=None, fgcolor='black', # text color
441 min_fsize=6, max_fsize=15,
442 ftype='sans-serif',
443 tooltip=None,
444 name="",
445 padding_x=0, padding_y=0, stroke_color=None, stroke_width=0):
447 Face.__init__(self, name=name, padding_x=padding_x, padding_y=padding_y)
449 self.width = width
450 self.height = height
451 self.stretch = True
452 self.color = color
453 self.opacity = opacity
454 # Text related
455 self.text = str(text) if text is not None else None
456 self.rotate_text = False
457 self.fgcolor = fgcolor
458 self.ftype = ftype
459 self.min_fsize = min_fsize
460 self.max_fsize = max_fsize
461 self.stroke_color = stroke_color
462 self.stroke_width = stroke_width
464 self.tooltip = tooltip
466 def __name__(self):
467 return "RectFace"
469 def compute_bounding_box(self,
470 drawer,
471 point, size,
472 dx_to_closest_child,
473 bdx, bdy,
474 bdy0, bdy1,
475 pos, row,
476 n_row, n_col,
477 dx_before, dy_before):
479 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
480 pos = swap_pos(pos)
482 box = super().compute_bounding_box(
483 drawer,
484 point, size,
485 dx_to_closest_child,
486 bdx, bdy,
487 bdy0, bdy1,
488 pos, row,
489 n_row, n_col,
490 dx_before, dy_before)
492 x, y, dx, dy = box
493 zx, zy = self.zoom
494 zx = 1 if self.stretch\
495 and pos.startswith('aligned')\
496 and drawer.TYPE != 'circ'\
497 else zx
499 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
501 def get_dimensions(max_width, max_height):
502 if not (max_width or max_height):
503 return 0, 0
504 if (type(max_width) in (int, float) and max_width <= 0) or\
505 (type(max_height) in (int, float) and max_height <= 0):
506 return 0, 0
508 width = self.width / zx if self.width is not None else None
509 height = self.height / zy if self.height is not None else None
511 if width is None:
512 return max_width or 0, min(height or float('inf'), max_height)
513 if height is None:
514 return min(width, max_width or float('inf')), max_height
516 hw_ratio = height / width
518 if max_width and width > max_width:
519 width = max_width
520 height = width * hw_ratio
521 if max_height and height > max_height:
522 height = max_height
523 if not self.stretch or drawer.TYPE == 'circ':
524 width = height / hw_ratio
526 height /= r # in circular drawer
527 return width, height
529 max_dy = dy * r # take into account circular mode
531 if pos == 'branch_top':
532 width, height = get_dimensions(dx, max_dy)
533 box = (x, y + dy - height, width, height) # container bottom
535 elif pos == 'branch_bottom':
536 width, height = get_dimensions(dx, max_dy)
537 box = (x, y, width, height) # container top
539 elif pos == 'branch_right':
540 width, height = get_dimensions(dx, max_dy)
541 box = (x, y + (dy - height) / 2, width, height)
543 elif pos.startswith('aligned'):
544 width, height = get_dimensions(None, dy)
545 # height = min(dy, (self.height - 2 * self.padding_y) / zy)
546 # width = min(self.width - 2 * self.padding_x) / zx
548 if pos == 'aligned_bottom':
549 y = y + dy - height
550 elif pos == 'aligned_top':
551 y = y
552 else:
553 y = y + (dy - height) / 2
555 box = (x, y, width, height)
557 self._box = Box(*box)
558 return self._box
560 def draw(self, drawer):
561 self._check_own_variables()
563 circ_drawer = drawer.TYPE == 'circ'
564 style = {
565 'fill': self.color,
566 'opacity': self.opacity,
567 'stroke': self.stroke_color,
568 'stroke-width': self.stroke_width
569 }
570 if self.text and circ_drawer:
571 rect_id = get_random_string(10)
572 style['id'] = rect_id
574 yield draw_rect(self._box,
575 self.name,
576 style=style,
577 tooltip=self.tooltip)
579 if self.text:
580 x, y, dx, dy = self._box
581 zx, zy = self.zoom
583 r = (x or 1e-10) if circ_drawer else 1
584 if self.rotate_text:
585 rotation = 90
586 self.compute_fsize(dy * zy / (len(self.text) * zx) * r,
587 dx * zx / zy, zx, zy)
589 text_box = Box(x + (dx - self._fsize / (2 * zx)) / 2,
590 y + dy / 2,
591 dx, dy)
592 else:
593 rotation = 0
594 self.compute_fsize(dx / len(self.text), dy, zx, zy)
595 text_box = Box(x + dx / 2,
596 y + (dy - self._fsize / (zy * r)) / 2,
597 dx, dy)
598 text_style = {
599 'max_fsize': self._fsize,
600 'text_anchor': 'middle',
601 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
602 }
604 if circ_drawer:
605 offset = dx * zx + dy * zy * r / 2
606 # Turn text upside down on bottom
607 if y + dy / 2 > 0:
608 offset += dx * zx + dy * zy * r
609 text_style['offset'] = offset
611 yield draw_text(text_box,
612 self.text,
613 rotation=rotation,
614 anchor=('#' + str(rect_id)) if circ_drawer else None,
615 style=text_style)
618class ArrowFace(RectFace):
619 def __init__(self, width, height, orientation='right',
620 color='gray',
621 stroke_color='gray', stroke_width='1.5px',
622 tooltip=None,
623 text=None, fgcolor='black', # text color
624 min_fsize=6, max_fsize=15,
625 ftype='sans-serif',
626 name="",
627 padding_x=0, padding_y=0):
629 RectFace.__init__(self, width=width, height=height,
630 color=color, text=text, fgcolor=fgcolor,
631 min_fsize=min_fsize, max_fsize=max_fsize, ftype=ftype,
632 tooltip=tooltip,
633 name=name, padding_x=padding_x, padding_y=padding_y)
635 self.orientation = orientation
636 self.stroke_color = stroke_color
637 self.stroke_width = stroke_width
639 def __name__(self):
640 return "ArrowFace"
642 @property
643 def orientation(self):
644 return self._orientation
645 @orientation.setter
646 def orientation(self, value):
647 if value not in ('right', 'left'):
648 raise InvalidUsage('Wrong ArrowFace orientation {value}. Set value to "right" or "left"')
649 else:
650 self._orientation = value
652 def draw(self, drawer):
653 self._check_own_variables()
655 circ_drawer = drawer.TYPE == 'circ'
656 style = {
657 'fill': self.color,
658 'opacity': 0.7,
659 'stroke': self.stroke_color,
660 'stroke-width': self.stroke_width,
661 }
662 if self.text and circ_drawer:
663 rect_id = get_random_string(10)
664 style['id'] = rect_id
666 x, y, dx, dy = self._box
667 zx, zy = self.zoom
669 tip = min(5, dx * zx * 0.9) / zx
670 yield draw_arrow(self._box,
671 tip, self.orientation,
672 self.name,
673 style=style,
674 tooltip=self.tooltip)
676 if self.text:
677 r = (x or 1e-10) if circ_drawer else 1
678 if self.rotate_text:
679 rotation = 90
680 self.compute_fsize(dy * zy / (len(self.text) * zx) * r,
681 dx * zx / zy, zx, zy)
683 text_box = Box(x + (dx - self._fsize / (2 * zx)) / 2,
684 y + dy / 2,
685 dx, dy)
686 else:
687 rotation = 0
688 self.compute_fsize(dx / len(self.text), dy, zx, zy)
689 text_box = Box(x + dx / 2,
690 y + (dy - self._fsize / (zy * r)) / 2,
691 dx, dy)
692 text_style = {
693 'max_fsize': self._fsize,
694 'text_anchor': 'middle',
695 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
696 'pointer-events': 'none',
697 }
699 if circ_drawer:
700 offset = dx * zx + dy * zy * r / 2
701 # Turn text upside down on bottom
702 if y + dy / 2 > 0:
703 offset += dx * zx + dy * zy * r
704 text_style['offset'] = offset
706 yield draw_text(text_box,
707 self.text,
708 rotation=rotation,
709 anchor=('#' + str(rect_id)) if circ_drawer else None,
710 style=text_style)
714# Selected faces
715class SelectedFace(Face):
716 def __init__(self, name):
717 self.name = clean_text(name)
718 self.name = f'selected_results_{self.name}'
720 def __name__(self):
721 return "SelectedFace"
723class SelectedCircleFace(SelectedFace, CircleFace):
724 def __init__(self, name, radius=15,
725 padding_x=0, padding_y=0):
727 SelectedFace.__init__(self, name)
729 CircleFace.__init__(self, radius=radius, color=None,
730 name=self.name,
731 padding_x=padding_x, padding_y=padding_y)
733 def __name__(self):
734 return "SelectedCircleFace"
736class SelectedRectFace(SelectedFace, RectFace):
737 def __init__(self, name, width=15, height=15,
738 text=None,
739 padding_x=1, padding_y=0):
741 SelectedFace.__init__(self, name);
743 RectFace.__init__(self, width=width, height=height, color=None,
744 name=self.name, text=text,
745 padding_x=padding_x, padding_y=padding_y)
747 def __name__(self):
748 return "SelectedRectFace"
751class OutlineFace(Face):
752 def __init__(self,
753 stroke_color=None, stroke_width=None,
754 color=None, opacity=0.3,
755 collapsing_height=5, # height in px at which outline becomes a line
756 padding_x=0, padding_y=0):
758 Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
760 self.outline = None
761 self.collapsing_height = collapsing_height
763 self.always_drawn = True
765 def __name__(self):
766 return "OutlineFace"
768 def compute_bounding_box(self,
769 drawer,
770 point, size,
771 dx_to_closest_child,
772 bdx, bdy,
773 bdy0, bdy1,
774 pos, row,
775 n_row, n_col,
776 dx_before, dy_before):
778 self.outline = drawer.outline if drawer.outline \
779 and len(drawer.outline) == 4 else Box(0, 0, 0, 0)
781 self.zoom = drawer.zoom[0], drawer.zoom[1]
783 if drawer.TYPE == 'circ':
784 r, a, dr, da = self.outline
785 a1, a2 = clip_angles(a, a + da)
786 self.outline = Box(r, a1, dr, a2 - a1)
788 return self.get_box()
790 def get_box(self):
791 if self.outline and len(self.outline) == 4:
792 x, y, dx, dy = self.outline
793 return Box(x, y, dx, dy)
794 return Box(0, 0, 0, 0)
796 def fits(self):
797 return True
799 def draw(self, drawer):
800 nodestyle = self.node.sm_style
801 style = {
802 'stroke': nodestyle["outline_line_color"],
803 'stroke-width': nodestyle["outline_line_width"],
804 'fill': nodestyle["outline_color"],
805 'fill-opacity': nodestyle["outline_opacity"],
806 }
807 x, y, dx, dy = self.outline
808 zx, zy = self.zoom
809 circ_drawer = drawer.TYPE == 'circ'
810 r = (x or 1e-10) if circ_drawer else 1
811 if dy * zy * r < self.collapsing_height:
812 # Convert to line if height less than one pixel
813 p1 = (x, y + dy / 2)
814 p2 = (x + dx, y + dy / 2)
815 if circ_drawer:
816 p1 = cartesian(p1)
817 p2 = cartesian(p2)
818 yield draw_line(p1, p2, style=style)
819 else:
820 yield draw_outline(self.outline, style=style)
823class AlignLinkFace(Face):
824 def __init__(self,
825 stroke_color='gray', stroke_width=0.5,
826 line_type=1, opacity=0.8):
827 """Line types: 0 solid, 1 dotted, 2 dashed"""
829 Face.__init__(self, padding_x=0, padding_y=0)
831 self.line = None
833 self.stroke_color = stroke_color
834 self.stroke_width = stroke_width
835 self.type = line_type;
836 self.opacity = opacity
838 self.always_drawn = True
840 def __name__(self):
841 return "AlignLinkFace"
843 def compute_bounding_box(self,
844 drawer,
845 point, size,
846 dx_to_closest_child,
847 bdx, bdy,
848 bdy0, bdy1,
849 pos, row,
850 n_row, n_col,
851 dx_before, dy_before):
853 if drawer.NPANELS > 1 and drawer.viewport and pos == 'branch_right':
854 x, y = point
855 dx, dy = size
856 p1 = (x + bdx + dx_before, y + dy/2)
857 if drawer.TYPE == 'rect':
858 p2 = (drawer.viewport.x + drawer.viewport.dx, y + dy/2)
859 else:
860 aligned = sorted(drawer.tree_style.aligned_grid_dxs.items())
861 # p2 = (drawer.node_size(drawer.tree)[0], y + dy/2)
862 if not len(aligned):
863 return Box(0, 0, 0, 0)
864 p2 = (aligned[0][1] - bdx, y + dy/2)
865 if p1[0] > p2[0]:
866 return Box(0, 0, 0, 0)
867 p1, p2 = cartesian(p1), cartesian(p2)
869 self.line = (p1, p2)
871 return Box(0, 0, 0, 0) # Should not take space
873 def get_box(self):
874 return Box(0, 0, 0, 0) # Should not take space
876 def fits(self):
877 return True
879 def draw(self, drawer):
881 if drawer.NPANELS < 2:
882 return None
884 style = {
885 'type': self.type,
886 'stroke': self.stroke_color,
887 'stroke-width': self.stroke_width,
888 'opacity': self.opacity,
889 }
890 if drawer.panel == 0 and drawer.viewport and\
891 (self.node.is_leaf or self.node.is_collapsed)\
892 and self.line:
893 p1, p2 = self.line
894 yield draw_line(p1, p2, 'align-link', style=style)
897class SeqFace(Face):
898 def __init__(self, seq, seqtype='aa', poswidth=15,
899 draw_text=True, max_fsize=15, ftype='sans-serif',
900 padding_x=0, padding_y=0):
902 Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
904 self.seq = seq
905 self.seqtype = seqtype
906 self.colors = _aacolors if self.seqtype == 'aa' else _ntcolors
907 self.poswidth = poswidth # width of each nucleotide/aa
909 # Text
910 self.draw_text = draw_text
911 self.ftype = ftype
912 self.max_fsize = max_fsize
913 self._fsize = None
915 def __name__(self):
916 return "SeqFace"
918 def compute_bounding_box(self,
919 drawer,
920 point, size,
921 dx_to_closest_child,
922 bdx, bdy,
923 bdy0, bdy1,
924 pos, row,
925 n_row, n_col,
926 dx_before, dy_before):
928 if pos not in ('branch_right', 'aligned'):
929 raise InvalidUsage(f'Position {pos} not allowed for SeqFace')
931 box = super().compute_bounding_box(
932 drawer,
933 point, size,
934 dx_to_closest_child,
935 bdx, bdy,
936 bdy0, bdy1,
937 pos, row,
938 n_row, n_col,
939 dx_before, dy_before)
941 x, y, _, dy = box
942 zx, zy = self.zoom
943 dx = self.poswidth * len(self.seq) / zx
945 if self.draw_text:
946 self.compute_fsize(self.poswidth / zx, dy, zx, zy)
948 self._box = Box(x, y, dx, dy)
950 return self._box
952 def draw(self, drawer):
953 x0, y, _, dy = self._box
954 zx, zy = self.zoom
956 dx = self.poswidth / zx
957 # Send sequences as a whole to be rendered by PIXIjs
958 if self.draw_text:
959 aa_type = "text"
960 else:
961 aa_type = "notext"
963 yield [ f'pixi-aa_{aa_type}', Box(x0, y, dx * len(self.seq), dy), self.seq ]
965 # Rende text if necessary
966 # if self.draw_text:
967 # text_style = {
968 # 'max_fsize': self._fsize,
969 # 'text_anchor': 'middle',
970 # 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
971 # }
972 # for idx, pos in enumerate(self.seq):
973 # x = x0 + idx * dx
974 # r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
975 # # Draw rect
976 # if pos != '-':
977 # text_box = Box(x + dx / 2,
978 # y + (dy - self._fsize / (zy * r)) / 2,
979 # dx, dy)
980 # yield draw_text(text_box,
981 # pos,
982 # style=text_style)
985class SeqMotifFace(Face):
986 def __init__(self, seq=None, motifs=None, seqtype='aa',
987 gap_format='line', seq_format='[]',
988 width=None, height=None, # max height
989 fgcolor='black', bgcolor='#bcc3d0', gapcolor='gray',
990 gap_linewidth=0.2,
991 max_fsize=12, ftype='sans-serif',
992 padding_x=0, padding_y=0):
994 if not motifs and not seq:
995 raise ValueError(
996 "At least one argument (seq or motifs) should be provided.")
998 Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
1000 self.seq = seq or '-' * max([m[1] for m in motifs])
1001 self.seqtype = seqtype
1003 self.autoformat = True # block if 1px contains > 1 tile
1005 self.motifs = motifs
1006 self.overlaping_motif_opacity = 0.5
1008 self.seq_format = seq_format
1009 self.gap_format = gap_format
1010 self.gap_linewidth = gap_linewidth
1011 self.compress_gaps = False
1013 self.poswidth = 0.5
1014 self.w_scale = 1
1015 self.width = width # sum of all regions' width if not provided
1016 self.height = height # dynamically computed if not provided
1018 self.fg = '#000'
1019 self.bg = _aacolors if self.seqtype == 'aa' else _ntcolors
1020 self.fgcolor = fgcolor
1021 self.bgcolor = bgcolor
1022 self.gapcolor = gapcolor
1024 self.triangles = {'^': 'top', '>': 'right', 'v': 'bottom', '<': 'left'}
1026 # Text
1027 self.ftype = ftype
1028 self._min_fsize = 8
1029 self.max_fsize = max_fsize
1030 self._fsize = None
1032 self.regions = []
1033 self.build_regions()
1035 def __name__(self):
1036 return "SeqMotifFace"
1038 def build_regions(self):
1039 """Build and sort sequence regions: seq representation and motifs"""
1040 seq = self.seq
1041 motifs = deepcopy(self.motifs)
1043 # if only sequence is provided, build regions out of gap spaces
1044 if not motifs:
1045 if self.seq_format == "seq":
1046 motifs = [[0, len(seq), "seq",
1047 15, self.height, None, None, None]]
1048 else:
1049 motifs = []
1050 pos = 0
1051 for reg in re.split('([^-]+)', seq):
1052 if reg:
1053 if not reg.startswith("-"):
1054 motifs.append([pos, pos+len(reg)-1,
1055 self.seq_format,
1056 self.poswidth, self.height,
1057 self.fgcolor, self.bgcolor, None])
1058 pos += len(reg)
1060 motifs.sort()
1062 # complete missing regions
1063 current_seq_pos = 0
1064 for index, mf in enumerate(motifs):
1065 start, end, typ, w, h, fg, bg, name = mf
1066 if start > current_seq_pos:
1067 pos = current_seq_pos
1068 for reg in re.split('([^-]+)', seq[current_seq_pos:start]):
1069 if reg:
1070 if reg.startswith("-") and self.seq_format != "seq":
1071 self.regions.append([pos, pos+len(reg)-1,
1072 "gap_"+self.gap_format, self.poswidth, self.height,
1073 self.gapcolor, None, None])
1074 else:
1075 self.regions.append([pos, pos+len(reg)-1,
1076 self.seq_format, self.poswidth, self.height,
1077 self.fgcolor, self.bgcolor, None])
1078 pos += len(reg)
1079 current_seq_pos = start
1081 self.regions.append(mf)
1082 current_seq_pos = end + 1
1084 if len(seq) > current_seq_pos:
1085 pos = current_seq_pos
1086 for reg in re.split('([^-]+)', seq[current_seq_pos:]):
1087 if reg:
1088 if reg.startswith("-") and self.seq_format != "seq":
1089 self.regions.append([pos, pos+len(reg)-1,
1090 "gap_"+self.gap_format,
1091 self.poswidth, 1,
1092 self.gapcolor, None, None])
1093 else:
1094 self.regions.append([pos, pos+len(reg)-1,
1095 self.seq_format,
1096 self.poswidth, self.height,
1097 self.fgcolor, self.bgcolor, None])
1098 pos += len(reg)
1100 # Compute total width and
1101 # Detect overlapping, reducing opacity in overlapping elements
1102 total_width = 0
1103 prev_end = -1
1104 for idx, (start, end, shape, w, *_) in enumerate(self.regions):
1105 overlapping = abs(min(start - 1 - prev_end, 0))
1106 w = self.poswidth if shape.startswith("gap_") and self.compress_gaps else w
1107 total_width += (w or self.poswidth) * (end + 1 - start - overlapping)
1108 prev_end = end
1109 opacity = self.overlaping_motif_opacity if overlapping else 1
1110 self.regions[idx].append(opacity)
1111 if overlapping:
1112 self.regions[idx - 1][-1] = opacity
1114 if self.width:
1115 self.w_scale = self.width / total_width
1116 else:
1117 self.width = total_width
1119 def compute_bounding_box(self,
1120 drawer,
1121 point, size,
1122 dx_to_closest_child,
1123 bdx, bdy,
1124 bdy0, bdy1,
1125 pos, row,
1126 n_row, n_col,
1127 dx_before, dy_before):
1129 if pos != 'branch_right' and not pos.startswith('aligned'):
1130 raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace')
1132 box = super().compute_bounding_box(
1133 drawer,
1134 point, size,
1135 dx_to_closest_child,
1136 bdx, bdy,
1137 bdy0, bdy1,
1138 pos, row,
1139 n_row, n_col,
1140 dx_before, dy_before)
1142 x, y, _, dy = box
1143 zx, zy = self.zoom
1145 self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
1147 self._box = Box(x, y, self.width / zx, dy)
1148 return self._box
1150 def fits(self):
1151 return True
1153 def draw(self, drawer):
1154 # Only leaf/collapsed branch_right or aligned
1155 x0, y, _, dy = self._box
1156 zx, zy = self.zoom
1158 if self.viewport and len(self.seq):
1159 vx0, vx1 = self.viewport
1160 too_small = ((vx1 - vx0) * zx) / (len(self.seq) / zx) < 3
1161 if self.seq_format in [ "seq", "compactseq" ] and too_small:
1162 self.seq_format = "[]"
1163 self.regions = []
1164 self.build_regions()
1165 if self.seq_format == "[]" and not too_small:
1166 self.seq_format = "seq"
1167 self.regions = []
1168 self.build_regions()
1171 x = x0
1172 prev_end = -1
1174 if self.gap_format in ["line", "-"]:
1175 p1 = (x0, y + dy / 2)
1176 p2 = (x0 + self.width, y + dy / 2)
1177 if drawer.TYPE == 'circ':
1178 p1 = cartesian(p1)
1179 p2 = cartesian(p2)
1180 yield draw_line(p1, p2, style={'stroke-width': self.gap_linewidth,
1181 'stroke': self.gapcolor})
1183 for item in self.regions:
1184 if len(item) == 9:
1185 start, end, shape, posw, h, fg, bg, text, opacity = item
1186 else:
1187 continue
1189 # if not self.in_aligned_viewport((start / zx, end / zx)):
1190 # continue
1192 posw = (posw or self.poswidth) * self.w_scale
1193 w = posw * (end + 1 - start)
1194 style = { 'fill': bg, 'opacity': opacity }
1196 # Overlapping
1197 overlapping = abs(min(start - 1 - prev_end, 0))
1198 if overlapping:
1199 x -= posw * overlapping
1200 prev_end = end
1202 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
1203 default_h = dy * zy * r
1204 h = min([h or default_h, self.height or default_h, default_h]) / zy
1205 box = Box(x, y + (dy - h / r) / 2, w, h / r)
1207 if shape.startswith("gap_"):
1208 if self.compress_gaps:
1209 w = posw
1210 x += w
1211 continue
1213 # Line
1214 if shape in ['line', '-']:
1215 p1 = (x, y + dy / 2)
1216 p2 = (x + w, y + dy / 2)
1217 if drawer.TYPE == 'circ':
1218 p1 = cartesian(p1)
1219 p2 = cartesian(p2)
1220 yield draw_line(p1, p2, style={'stroke-width': 0.5, 'stroke': fg})
1222 # Rectangle
1223 elif shape == '[]':
1224 yield [ "pixi-block", box ]
1226 elif shape == '()':
1227 style['rounded'] = 1;
1228 yield draw_rect(box, '', style=style)
1230 # Rhombus
1231 elif shape == '<>':
1232 yield draw_rhombus(box, style=style)
1234 # Triangle
1235 elif shape in self.triangles.keys():
1236 box = Box(x, y + (dy - h / r) / 2, w, h / r)
1237 yield draw_triangle(box, self.triangles[shape], style=style)
1239 # Circle/ellipse
1240 elif shape == 'o':
1241 center = (x + w / 2, y + dy / 2)
1242 rx = w * zx / 2
1243 ry = h * zy / 2
1244 if rx == ry:
1245 yield draw_circle(center, rx, style=style)
1246 else:
1247 yield draw_ellipse(center, rx, ry, style=style)
1249 # Sequence and compact sequence
1250 elif shape in ['seq', 'compactseq']:
1251 seq = self.seq[start : end + 1]
1252 if self.viewport:
1253 sx, sy, sw, sh = box
1254 sposw = sw / len(seq)
1255 viewport_start = self.viewport[0] - self.viewport_margin / zx
1256 viewport_end = self.viewport[1] + self.viewport_margin / zx
1257 sm_x = max(viewport_start - sx, 0)
1258 sm_start = round(sm_x / sposw)
1259 sm_end = len(seq) - round(max(sx + sw - viewport_end, 0) / sposw)
1260 seq = seq[sm_start:sm_end]
1261 sm_box = (sm_x, sy, sposw * len(seq), sh)
1262 if shape == 'compactseq' or posw * zx < self._min_fsize:
1263 aa_type = "notext"
1264 else:
1265 aa_type = "text"
1266 yield [ f'pixi-aa_{aa_type}', sm_box, seq ]
1269 # Text on top of shape
1270 if text:
1271 try:
1272 ftype, fsize, color, text = text.split("|")
1273 fsize = int(fsize)
1274 except:
1275 ftype, fsize, color = self.ftype, self.max_fsize, (fg or self.fcolor)
1276 self.compute_fsize(w / len(text), h, zx, zy, fsize)
1277 text_box = Box(x + w / 2,
1278 y + (dy - self._fsize / (zy * r)) / 2,
1279 self._fsize / (zx * CHAR_HEIGHT),
1280 self._fsize / zy)
1281 text_style = {
1282 'max_fsize': self._fsize,
1283 'text_anchor': 'middle',
1284 'ftype': f'{ftype}, sans-serif',
1285 'fill': color,
1286 }
1287 yield draw_text(text_box, text, style=text_style)
1289 # Update x to draw consecutive motifs
1290 x += w
1293class AlignmentFace(Face):
1294 def __init__(self, seq, seqtype='aa',
1295 gap_format='line', seq_format='[]',
1296 width=None, height=None, # max height
1297 fgcolor='black', bgcolor='#bcc3d0', gapcolor='gray',
1298 gap_linewidth=0.2,
1299 max_fsize=12, ftype='sans-serif',
1300 padding_x=0, padding_y=0):
1302 Face.__init__(self, padding_x=padding_x, padding_y=padding_y)
1304 self.seq = seq
1305 self.seqlength = len(self.seq)
1306 self.seqtype = seqtype
1308 self.autoformat = True # block if 1px contains > 1 tile
1310 self.seq_format = seq_format
1311 self.gap_format = gap_format
1312 self.gap_linewidth = gap_linewidth
1313 self.compress_gaps = False
1315 self.poswidth = 5
1316 self.w_scale = 1
1317 self.width = width # sum of all regions' width if not provided
1318 self.height = height # dynamically computed if not provided
1320 total_width = self.seqlength * self.poswidth
1321 if self.width:
1322 self.w_scale = self.width / total_width
1323 else:
1324 self.width = total_width
1326 self.bg = _aacolors if self.seqtype == 'aa' else _ntcolors
1327 # self.fgcolor = fgcolor
1328 # self.bgcolor = bgcolor
1329 self.gapcolor = gapcolor
1331 # Text
1332 self.ftype = ftype
1333 self._min_fsize = 8
1334 self.max_fsize = max_fsize
1335 self._fsize = None
1337 self.blocks = []
1338 self.build_blocks()
1340 def __name__(self):
1341 return "AlignmentFace"
1343 def get_seq(self, start, end):
1344 """Retrieves sequence given start, end"""
1345 return self.seq[start:end]
1347 def build_blocks(self):
1348 pos = 0
1349 for reg in re.split('([^-]+)', self.seq):
1350 if reg:
1351 if not reg.startswith("-"):
1352 self.blocks.append([pos, pos + len(reg) - 1])
1353 pos += len(reg)
1355 self.blocks.sort()
1357 def compute_bounding_box(self,
1358 drawer,
1359 point, size,
1360 dx_to_closest_child,
1361 bdx, bdy,
1362 bdy0, bdy1,
1363 pos, row,
1364 n_row, n_col,
1365 dx_before, dy_before):
1367 if pos != 'branch_right' and not pos.startswith('aligned'):
1368 raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace')
1370 box = super().compute_bounding_box(
1371 drawer,
1372 point, size,
1373 dx_to_closest_child,
1374 bdx, bdy,
1375 bdy0, bdy1,
1376 pos, row,
1377 n_row, n_col,
1378 dx_before, dy_before)
1380 x, y, _, dy = box
1382 zx, zy = self.zoom
1383 zx = 1 if drawer.TYPE != 'circ' else zx
1385 # zx = drawer.zoom[0]
1386 # self.zoom = (zx, zy)
1388 if drawer.TYPE == "circ":
1389 self.viewport = (0, drawer.viewport.dx)
1390 else:
1391 self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
1393 self._box = Box(x, y, self.width / zx, dy)
1394 return self._box
1396 def draw(self, drawer):
1397 def get_height(x, y):
1398 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1
1399 default_h = dy * zy * r
1400 h = min([self.height or default_h, default_h]) / zy
1401 # h /= r
1402 return y + (dy - h) / 2, h
1404 # Only leaf/collapsed branch_right or aligned
1405 x0, y, dx, dy = self._box
1406 zx, zy = self.zoom
1407 zx = drawer.zoom[0] if drawer.TYPE == 'circ' else zx
1410 if self.gap_format in ["line", "-"]:
1411 p1 = (x0, y + dy / 2)
1412 p2 = (x0 + self.width, y + dy / 2)
1413 if drawer.TYPE == 'circ':
1414 p1 = cartesian(p1)
1415 p2 = cartesian(p2)
1416 yield draw_line(p1, p2, style={'stroke-width': self.gap_linewidth,
1417 'stroke': self.gapcolor})
1418 vx0, vx1 = self.viewport
1419 too_small = (self.width * zx) / (self.seqlength) < 1
1421 posw = self.poswidth * self.w_scale
1422 viewport_start = vx0 - self.viewport_margin / zx
1423 viewport_end = vx1 + self.viewport_margin / zx
1424 sm_x = max(viewport_start - x0, 0)
1425 sm_start = round(sm_x / posw)
1426 w = self.seqlength * posw
1427 sm_x0 = x0 if drawer.TYPE == "rect" else 0
1428 sm_end = self.seqlength - round(max(sm_x0 + w - viewport_end, 0) / posw)
1430 if too_small or self.seq_format == "[]":
1431 for start, end in self.blocks:
1432 if end >= sm_start and start <= sm_end:
1433 bstart = max(sm_start, start)
1434 bend = min(sm_end, end)
1435 bx = x0 + bstart * posw
1436 by, bh = get_height(bx, y)
1437 box = Box(bx, by, (bend + 1 - bstart) * posw, bh)
1438 yield [ "pixi-block", box ]
1440 else:
1441 seq = self.get_seq(sm_start, sm_end)
1442 sm_x = sm_x if drawer.TYPE == 'rect' else x0
1443 y, h = get_height(sm_x, y)
1444 sm_box = Box(sm_x0 + sm_x, y, posw * len(seq), h)
1446 if self.seq_format == 'compactseq' or posw * zx < self._min_fsize:
1447 aa_type = "notext"
1448 else:
1449 aa_type = "text"
1450 yield [ f'pixi-aa_{aa_type}', sm_box, seq ]
1454class ScaleFace(Face):
1455 def __init__(self, name='', width=None, color='black',
1456 scale_range=(0, 0), tick_width=80, line_width=1,
1457 formatter='%.0f',
1458 min_fsize=6, max_fsize=12, ftype='sans-serif',
1459 padding_x=0, padding_y=0):
1461 Face.__init__(self, name=name,
1462 padding_x=padding_x, padding_y=padding_y)
1464 self.width = width
1465 self.height = None
1466 self.range = scale_range
1468 self.color = color
1469 self.min_fsize = min_fsize
1470 self.max_fsize = max_fsize
1471 self._fsize = max_fsize
1472 self.ftype = ftype
1473 self.formatter = formatter
1475 self.tick_width = tick_width
1476 self.line_width = line_width
1478 self.vt_line_height = 10
1480 def __name__(self):
1481 return "ScaleFace"
1483 def compute_bounding_box(self,
1484 drawer,
1485 point, size,
1486 dx_to_closest_child,
1487 bdx, bdy,
1488 bdy0, bdy1,
1489 pos, row,
1490 n_row, n_col,
1491 dx_before, dy_before):
1493 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2:
1494 pos = swap_pos(pos)
1496 box = super().compute_bounding_box(
1497 drawer,
1498 point, size,
1499 dx_to_closest_child,
1500 bdx, bdy,
1501 bdy0, bdy1,
1502 pos, row,
1503 n_row, n_col,
1504 dx_before, dy_before)
1506 x, y, _, dy = box
1507 zx, zy = self.zoom
1509 self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx)
1511 self.height = (self.line_width + 10 + self.max_fsize) / zy
1513 height = min(dy, self.height)
1515 if pos == "aligned_bottom":
1516 y = y + dy - height
1518 self._box = Box(x, y, self.width / zx, height)
1519 return self._box
1521 def draw(self, drawer):
1522 x0, y, _, dy = self._box
1523 zx, zy = self.zoom
1525 p1 = (x0, y + dy - 5 / zy)
1526 p2 = (x0 + self.width, y + dy - self.vt_line_height / (2 * zy))
1527 if drawer.TYPE == 'circ':
1528 p1 = cartesian(p1)
1529 p2 = cartesian(p2)
1530 yield draw_line(p1, p2, style={'stroke-width': self.line_width,
1531 'stroke': self.color})
1534 nticks = round((self.width * zx) / self.tick_width)
1535 dx = self.width / nticks
1536 range_factor = (self.range[1] - self.range[0]) / self.width
1538 if self.viewport:
1539 sm_start = round(max(self.viewport[0] - self.viewport_margin - x0, 0) / dx)
1540 sm_end = nticks - round(max(x0 + self.width - (self.viewport[1] +
1541 self.viewport_margin), 0) / dx)
1542 else:
1543 sm_start, sm_end = 0, nticks
1545 for i in range(sm_start, sm_end + 1):
1546 x = x0 + i * dx
1547 number = range_factor * i * dx
1549 if number == 0:
1550 text = "0"
1551 else:
1552 text = self.formatter % number if self.formatter else str(number)
1554 text = text.rstrip('0').rstrip('.') if '.' in text else text
1556 self.compute_fsize(self.tick_width / len(text), dy, zx, zy)
1557 text_style = {
1558 'max_fsize': self._fsize,
1559 'text_anchor': 'middle',
1560 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
1561 }
1562 text_box = Box(x,
1563 y,
1564 # y + (dy - self._fsize / (zy * r)) / 2,
1565 dx, dy)
1567 yield draw_text(text_box, text, style=text_style)
1569 p1 = (x, y + dy - self.vt_line_height / zy)
1570 p2 = (x, y + dy)
1572 yield draw_line(p1, p2, style={'stroke-width': self.line_width,
1573 'stroke': self.color})
1576class PieChartFace(CircleFace):
1578 def __init__(self, radius, data, name="",
1579 padding_x=0, padding_y=0, tooltip=None):
1581 super().__init__(self, name=name, color=None,
1582 padding_x=padding_x, padding_y=padding_y, tooltip=tooltip)
1584 self.radius = radius
1585 # Drawing private properties
1586 self._max_radius = 0
1587 self._center = (0, 0)
1589 # data = [ [name, value, color, tooltip], ... ]
1590 # self.data = [ (name, value, color, tooltip, a, da) ]
1591 self.data = []
1592 self.compute_pie(list(data))
1594 def __name__(self):
1595 return "PieChartFace"
1597 def compute_pie(self, data):
1598 total_value = sum(d[1] for d in data)
1600 a = 0
1601 for name, value, color, tooltip in data:
1602 da = (value / total_value) * 2 * pi
1603 self.data.append((name, value, color, tooltip, a, da))
1604 a += da
1606 assert a >= 2 * pi - 1e-5 and a <= 2 * pi + 1e-5, "Incorrect pie"
1608 def draw(self, drawer):
1609 # Draw circle if only one datum
1610 if len(self.data) == 1:
1611 self.color = self.data[0][2]
1612 yield from CircleFace.draw(self, drawer)
1614 else:
1615 for name, value, color, tooltip, a, da in self.data:
1616 style = { 'fill': color }
1617 yield draw_slice(self._center, self._max_radius, a, da,
1618 "", style=style, tooltip=tooltip)
1621class HTMLFace(RectFace):
1622 def __init__(self, html, width, height, name="", padding_x=0, padding_y=0):
1624 RectFace.__init__(self, width=width, height=height,
1625 name=name, padding_x=padding_x, padding_y=padding_y)
1627 self.content = html
1629 def __name__(self):
1630 return "HTMLFace"
1632 def draw(self, drawer):
1633 yield draw_html(self._box, self.content)
1636class ImgFace(RectFace):
1637 def __init__(self, img_path, width, height, name="", padding_x=0, padding_y=0):
1639 RectFace.__init__(self, width=width, height=height,
1640 name=name, padding_x=padding_x, padding_y=padding_y)
1644 with open(img_path, "rb") as handle:
1645 img = base64.b64encode(handle.read()).decode("utf-8")
1646 extension = pathlib.Path(img_path).suffix[1:]
1647 if extension not in ALLOWED_IMG_EXTENSIONS:
1648 print("The image does not have an allowed format: " +
1649 extension + " not in " + str(ALLOWED_IMG_EXTENSIONS))
1651 self.content = f'data:image/{extension};base64,{img}'
1653 self.stretch = False
1655 def __name__(self):
1656 return "ImgFace"
1658 def draw(self, drawer):
1659 yield draw_img(self._box, self.content)
1662class LegendFace(Face):
1664 def __init__(self,
1665 colormap,
1666 title,
1667 min_fsize=6, max_fsize=15, ftype='sans-serif',
1668 padding_x=0, padding_y=0):
1670 Face.__init__(self, name=title,
1671 padding_x=padding_x, padding_y=padding_y)
1673 self._content = True
1674 self.title = title
1675 self.min_fsize = min_fsize
1676 self.max_fsize = max_fsize
1677 self._fsize = max_fsize
1678 self.ftype = ftype
1680 def __name__(self):
1681 return "LegendFace"
1683 def draw(self, drawer):
1684 self._check_own_variables()
1686 style = {'fill': self.color, 'opacity': self.opacity}
1688 x, y, dx, dy = self._box
1689 zx, zy = self.zoom
1691 entry_h = min(15 / zy, dy / (len(self.colormap.keys()) + 2))
1693 title_box = Box(x, y + 5, dx, entry_h)
1694 text_style = {
1695 'max_fsize': self.compute_fsize(title_box.dx, title_box.dy, zx, zy),
1696 'text_anchor': 'middle',
1697 'ftype': f'{self.ftype}, sans-serif', # default sans-serif
1698 }
1700 yield draw_text(title_box,
1701 self.title,
1702 style=text_style)
1704 entry_y = y + 2 * entry_h
1705 for value, color in self.colormap.items():
1706 text_box = Box(x, entry_y, dx, entry_h)
1707 yield draw_text(text_box,
1708 value,
1709 style=text_style)
1710 ty += entry_h
1713class StackedBarFace(RectFace):
1714 """Face to show a series of stacked bars."""
1716 def __init__(self, width, height, data=None, name='', opacity=0.7,
1717 min_fsize=6, max_fsize=15, ftype='sans-serif',
1718 padding_x=0, padding_y=0, tooltip=None):
1719 """Initialize the face.
1721 :param data: List of tuples, like [(whatever, value, color), ...].
1722 """
1723 super().__init__(width=width, height=height, name=name, color=None,
1724 min_fsize=min_fsize, max_fsize=max_fsize,
1725 padding_x=padding_x, padding_y=padding_y, tooltip=tooltip)
1726 self.data = data
1728 def __name__(self):
1729 return "StackedBarFace"
1731 def draw(self, drawer):
1732 x0, y0, dx0, dy0 = self._box
1734 total = sum(d[1] for d in self.data)
1735 scale_factor = dx0 / (total or 1) # the "or 1" prevents dividing by 0
1737 x = x0
1738 for _, value, color, *_ in self.data:
1739 dx = scale_factor * value
1740 box = Box(x, y0, dx, dy0)
1741 yield draw_rect(box, self.name, style={'fill': color},
1742 tooltip=self.tooltip)
1743 x += dx