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

1import base64 

2from collections.abc import Iterable 

3import pathlib 

4import re 

5from math import pi 

6 

7from ..utils import InvalidUsage, get_random_string 

8from .draw_helpers import * 

9from copy import deepcopy 

10 

11CHAR_HEIGHT = 1.4 # char's height to width ratio 

12 

13ALLOWED_IMG_EXTENSIONS = [ "png", "svg", "jpeg" ] 

14 

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 } 

42 

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 } 

54 

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'] 

61 

62 

63def clean_text(text): 

64 return re.sub(r'[^A-Za-z0-9_-]', '', text) 

65 

66 

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 

74 

75 

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) 

82 

83 

84class Face: 

85 """ 

86 Base class for faces. 

87 

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 """ 

91 

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 

99 

100 self.always_drawn = False # Use carefully to avoid overheading... 

101 

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 

106 

107 def __name__(self): 

108 return "Face" 

109 

110 def in_aligned_viewport(self, segment): 

111 if self.viewport: 

112 return intersects_segment(self.viewport, segment) 

113 return True 

114 

115 def get_content(self): 

116 return self._content 

117 

118 def get_box(self): 

119 self._check_own_variables() 

120 return self._box 

121 

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]) 

124 

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): 

134 

135 self._check_own_content() 

136 x, y = point 

137 dx, dy = size 

138 

139 zx, zy, za = drawer.zoom 

140 if pos.startswith("aligned"): 

141 zx = za 

142 self.zoom = (zx, zy) 

143 

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 

149 

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 

155 

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 

163 

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 

170 

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 

177 

178 else: 

179 raise InvalidUsage(f'unkown position {pos}') 

180 

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) 

184 

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)) 

191 

192 return self._box 

193 

194 def fits(self): 

195 """ 

196 Return True if Face fits in computed box. 

197 Method overriden by inheriting classes. 

198 """ 

199 return True 

200 

201 def _check_own_content(self): 

202 if not self._content: 

203 raise Exception(f'**Content** has not been computed yet.') 

204 

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 

211 

212 

213class TextFace(Face): 

214 

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). 

221 

222 # FIXME: The rotation is not being taken into account when 

223 # computing the bounding box. 

224 

225 Face.__init__(self, name=name, 

226 padding_x=padding_x, padding_y=padding_y) 

227 

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 

236 

237 def __name__(self): 

238 return "TextFace" 

239 

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): 

249 

250 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2: 

251 pos = swap_pos(pos) 

252 

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) 

262 

263 zx, zy = self.zoom 

264 

265 x, y , dx, dy = box 

266 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1 

267 

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 

274 

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) 

281 

282 

283 if pos == 'branch_top': 

284 box = (x, y + dy - height, width, height) # container bottom 

285 

286 elif pos == 'branch_bottom': 

287 box = (x, y, width, height) # container top 

288 

289 elif pos == 'aligned_bottom': 

290 box = (x, y + dy - height, width, height) 

291 

292 elif pos == 'aligned_top': 

293 box = (x, y, width, height) 

294 

295 else: # branch_right and aligned 

296 box = (x, y + (dy - height) / 2, width, height) 

297 

298 self._box = Box(*box) 

299 return self._box 

300 

301 def fits(self): 

302 return self._content != "None" and self._fsize >= self.min_fsize 

303 

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) 

313 

314 

315class AttrFace(TextFace): 

316 

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): 

324 

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) 

330 

331 self._attr = attr 

332 self.formatter = formatter 

333 

334 def __name__(self): 

335 return "AttrFace" 

336 

337 def _check_own_node(self): 

338 if not self.node: 

339 raise Exception(f'An associated **node** must be provided to compute **content**.') 

340 

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 

346 

347 

348class CircleFace(Face): 

349 

350 def __init__(self, radius, color, name="", tooltip=None, 

351 padding_x=0, padding_y=0): 

352 

353 Face.__init__(self, name=name, 

354 padding_x=padding_x, padding_y=padding_y) 

355 

356 self.radius = radius 

357 self.color = color 

358 # Drawing private properties 

359 self._max_radius = 0 

360 self._center = (0, 0) 

361 

362 self.tooltip = tooltip 

363 

364 def __name__(self): 

365 return "CircleFace" 

366 

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): 

376 

377 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2: 

378 pos = swap_pos(pos) 

379 

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) 

389 

390 x, y, dx, dy = box 

391 zx, zy = self.zoom 

392 

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) 

395 

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) 

399 

400 cx = x + self._max_radius / zx - padding_x 

401 

402 if pos == 'branch_top': 

403 cy = y + dy - self._max_radius / (zy * r) # container bottom 

404 

405 elif pos == 'branch_bottom': 

406 cy = y + self._max_radius / (zy * r) # container top 

407 

408 else: # branch_right and aligned 

409 if pos == 'aligned': 

410 self._max_radius = min(dy * zy * r / 2, self.radius) 

411 

412 cx = x + self._max_radius / zx - padding_x # centered 

413 

414 if pos == 'aligned_bottom': 

415 cy = y + dy - self._max_radius / zy 

416 

417 elif pos == 'aligned_top': 

418 cy = y + self._max_radius / zy 

419 

420 else: 

421 cy = y + dy / 2 # centered 

422 

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) 

427 

428 return self._box 

429 

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) 

435 

436 

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): 

446 

447 Face.__init__(self, name=name, padding_x=padding_x, padding_y=padding_y) 

448 

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 

463 

464 self.tooltip = tooltip 

465 

466 def __name__(self): 

467 return "RectFace" 

468 

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): 

478 

479 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2: 

480 pos = swap_pos(pos) 

481 

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) 

491 

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 

498 

499 r = (x or 1e-10) if drawer.TYPE == 'circ' else 1 

500 

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 

507 

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 

510 

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 

515 

516 hw_ratio = height / width 

517 

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 

525 

526 height /= r # in circular drawer 

527 return width, height 

528 

529 max_dy = dy * r # take into account circular mode 

530 

531 if pos == 'branch_top': 

532 width, height = get_dimensions(dx, max_dy) 

533 box = (x, y + dy - height, width, height) # container bottom 

534 

535 elif pos == 'branch_bottom': 

536 width, height = get_dimensions(dx, max_dy) 

537 box = (x, y, width, height) # container top 

538 

539 elif pos == 'branch_right': 

540 width, height = get_dimensions(dx, max_dy) 

541 box = (x, y + (dy - height) / 2, width, height) 

542 

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 

547 

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 

554 

555 box = (x, y, width, height) 

556 

557 self._box = Box(*box) 

558 return self._box 

559 

560 def draw(self, drawer): 

561 self._check_own_variables() 

562 

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 

573 

574 yield draw_rect(self._box, 

575 self.name, 

576 style=style, 

577 tooltip=self.tooltip) 

578 

579 if self.text: 

580 x, y, dx, dy = self._box 

581 zx, zy = self.zoom 

582 

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) 

588 

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 } 

603 

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 

610 

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) 

616 

617 

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): 

628 

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) 

634 

635 self.orientation = orientation 

636 self.stroke_color = stroke_color 

637 self.stroke_width = stroke_width 

638 

639 def __name__(self): 

640 return "ArrowFace" 

641 

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 

651 

652 def draw(self, drawer): 

653 self._check_own_variables() 

654 

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 

665 

666 x, y, dx, dy = self._box 

667 zx, zy = self.zoom 

668 

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) 

675 

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) 

682 

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 } 

698 

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 

705 

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) 

711 

712 

713 

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}' 

719 

720 def __name__(self): 

721 return "SelectedFace" 

722 

723class SelectedCircleFace(SelectedFace, CircleFace): 

724 def __init__(self, name, radius=15, 

725 padding_x=0, padding_y=0): 

726 

727 SelectedFace.__init__(self, name) 

728 

729 CircleFace.__init__(self, radius=radius, color=None, 

730 name=self.name, 

731 padding_x=padding_x, padding_y=padding_y) 

732 

733 def __name__(self): 

734 return "SelectedCircleFace" 

735 

736class SelectedRectFace(SelectedFace, RectFace): 

737 def __init__(self, name, width=15, height=15, 

738 text=None, 

739 padding_x=1, padding_y=0): 

740 

741 SelectedFace.__init__(self, name); 

742 

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) 

746 

747 def __name__(self): 

748 return "SelectedRectFace" 

749 

750 

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): 

757 

758 Face.__init__(self, padding_x=padding_x, padding_y=padding_y) 

759 

760 self.outline = None 

761 self.collapsing_height = collapsing_height 

762 

763 self.always_drawn = True 

764 

765 def __name__(self): 

766 return "OutlineFace" 

767 

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): 

777 

778 self.outline = drawer.outline if drawer.outline \ 

779 and len(drawer.outline) == 4 else Box(0, 0, 0, 0) 

780 

781 self.zoom = drawer.zoom[0], drawer.zoom[1] 

782 

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) 

787 

788 return self.get_box() 

789 

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) 

795 

796 def fits(self): 

797 return True 

798 

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) 

821 

822 

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""" 

828 

829 Face.__init__(self, padding_x=0, padding_y=0) 

830 

831 self.line = None 

832 

833 self.stroke_color = stroke_color 

834 self.stroke_width = stroke_width 

835 self.type = line_type; 

836 self.opacity = opacity 

837 

838 self.always_drawn = True 

839 

840 def __name__(self): 

841 return "AlignLinkFace" 

842 

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): 

852 

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) 

868 

869 self.line = (p1, p2) 

870 

871 return Box(0, 0, 0, 0) # Should not take space 

872 

873 def get_box(self): 

874 return Box(0, 0, 0, 0) # Should not take space 

875 

876 def fits(self): 

877 return True 

878 

879 def draw(self, drawer): 

880 

881 if drawer.NPANELS < 2: 

882 return None 

883 

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) 

895 

896 

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): 

901 

902 Face.__init__(self, padding_x=padding_x, padding_y=padding_y) 

903 

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 

908 

909 # Text 

910 self.draw_text = draw_text 

911 self.ftype = ftype 

912 self.max_fsize = max_fsize 

913 self._fsize = None 

914 

915 def __name__(self): 

916 return "SeqFace" 

917 

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): 

927 

928 if pos not in ('branch_right', 'aligned'): 

929 raise InvalidUsage(f'Position {pos} not allowed for SeqFace') 

930 

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) 

940 

941 x, y, _, dy = box 

942 zx, zy = self.zoom 

943 dx = self.poswidth * len(self.seq) / zx 

944 

945 if self.draw_text: 

946 self.compute_fsize(self.poswidth / zx, dy, zx, zy) 

947 

948 self._box = Box(x, y, dx, dy) 

949 

950 return self._box 

951 

952 def draw(self, drawer): 

953 x0, y, _, dy = self._box 

954 zx, zy = self.zoom 

955 

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" 

962 

963 yield [ f'pixi-aa_{aa_type}', Box(x0, y, dx * len(self.seq), dy), self.seq ] 

964 

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) 

983 

984 

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): 

993 

994 if not motifs and not seq: 

995 raise ValueError( 

996 "At least one argument (seq or motifs) should be provided.") 

997 

998 Face.__init__(self, padding_x=padding_x, padding_y=padding_y) 

999 

1000 self.seq = seq or '-' * max([m[1] for m in motifs]) 

1001 self.seqtype = seqtype 

1002 

1003 self.autoformat = True # block if 1px contains > 1 tile 

1004 

1005 self.motifs = motifs 

1006 self.overlaping_motif_opacity = 0.5 

1007 

1008 self.seq_format = seq_format 

1009 self.gap_format = gap_format 

1010 self.gap_linewidth = gap_linewidth 

1011 self.compress_gaps = False 

1012 

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 

1017 

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 

1023 

1024 self.triangles = {'^': 'top', '>': 'right', 'v': 'bottom', '<': 'left'} 

1025 

1026 # Text 

1027 self.ftype = ftype 

1028 self._min_fsize = 8 

1029 self.max_fsize = max_fsize 

1030 self._fsize = None 

1031 

1032 self.regions = [] 

1033 self.build_regions() 

1034 

1035 def __name__(self): 

1036 return "SeqMotifFace" 

1037 

1038 def build_regions(self): 

1039 """Build and sort sequence regions: seq representation and motifs""" 

1040 seq = self.seq 

1041 motifs = deepcopy(self.motifs) 

1042 

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) 

1059 

1060 motifs.sort() 

1061 

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 

1080 

1081 self.regions.append(mf) 

1082 current_seq_pos = end + 1 

1083 

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) 

1099 

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 

1113 

1114 if self.width: 

1115 self.w_scale = self.width / total_width 

1116 else: 

1117 self.width = total_width 

1118 

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): 

1128 

1129 if pos != 'branch_right' and not pos.startswith('aligned'): 

1130 raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace') 

1131 

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) 

1141 

1142 x, y, _, dy = box 

1143 zx, zy = self.zoom 

1144 

1145 self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx) 

1146 

1147 self._box = Box(x, y, self.width / zx, dy) 

1148 return self._box 

1149 

1150 def fits(self): 

1151 return True 

1152 

1153 def draw(self, drawer): 

1154 # Only leaf/collapsed branch_right or aligned 

1155 x0, y, _, dy = self._box 

1156 zx, zy = self.zoom 

1157 

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() 

1169 

1170 

1171 x = x0 

1172 prev_end = -1 

1173 

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}) 

1182 

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 

1188 

1189 # if not self.in_aligned_viewport((start / zx, end / zx)): 

1190 # continue 

1191 

1192 posw = (posw or self.poswidth) * self.w_scale 

1193 w = posw * (end + 1 - start) 

1194 style = { 'fill': bg, 'opacity': opacity } 

1195 

1196 # Overlapping 

1197 overlapping = abs(min(start - 1 - prev_end, 0)) 

1198 if overlapping: 

1199 x -= posw * overlapping 

1200 prev_end = end 

1201 

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) 

1206 

1207 if shape.startswith("gap_"): 

1208 if self.compress_gaps: 

1209 w = posw 

1210 x += w 

1211 continue 

1212 

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}) 

1221 

1222 # Rectangle 

1223 elif shape == '[]': 

1224 yield [ "pixi-block", box ] 

1225 

1226 elif shape == '()': 

1227 style['rounded'] = 1; 

1228 yield draw_rect(box, '', style=style) 

1229 

1230 # Rhombus 

1231 elif shape == '<>': 

1232 yield draw_rhombus(box, style=style) 

1233 

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) 

1238 

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) 

1248 

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 ] 

1267 

1268 

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) 

1288 

1289 # Update x to draw consecutive motifs 

1290 x += w 

1291 

1292 

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): 

1301 

1302 Face.__init__(self, padding_x=padding_x, padding_y=padding_y) 

1303 

1304 self.seq = seq 

1305 self.seqlength = len(self.seq) 

1306 self.seqtype = seqtype 

1307 

1308 self.autoformat = True # block if 1px contains > 1 tile 

1309 

1310 self.seq_format = seq_format 

1311 self.gap_format = gap_format 

1312 self.gap_linewidth = gap_linewidth 

1313 self.compress_gaps = False 

1314 

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 

1319 

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 

1325 

1326 self.bg = _aacolors if self.seqtype == 'aa' else _ntcolors 

1327 # self.fgcolor = fgcolor 

1328 # self.bgcolor = bgcolor 

1329 self.gapcolor = gapcolor 

1330 

1331 # Text 

1332 self.ftype = ftype 

1333 self._min_fsize = 8 

1334 self.max_fsize = max_fsize 

1335 self._fsize = None 

1336 

1337 self.blocks = [] 

1338 self.build_blocks() 

1339 

1340 def __name__(self): 

1341 return "AlignmentFace" 

1342 

1343 def get_seq(self, start, end): 

1344 """Retrieves sequence given start, end""" 

1345 return self.seq[start:end] 

1346 

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) 

1354 

1355 self.blocks.sort() 

1356 

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): 

1366 

1367 if pos != 'branch_right' and not pos.startswith('aligned'): 

1368 raise InvalidUsage(f'Position {pos} not allowed for SeqMotifFace') 

1369 

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) 

1379 

1380 x, y, _, dy = box 

1381 

1382 zx, zy = self.zoom 

1383 zx = 1 if drawer.TYPE != 'circ' else zx 

1384 

1385 # zx = drawer.zoom[0] 

1386 # self.zoom = (zx, zy) 

1387 

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) 

1392 

1393 self._box = Box(x, y, self.width / zx, dy) 

1394 return self._box 

1395 

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 

1403 

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 

1408 

1409 

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 

1420 

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) 

1429 

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 ] 

1439 

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) 

1445 

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 ] 

1451 

1452 

1453 

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): 

1460 

1461 Face.__init__(self, name=name, 

1462 padding_x=padding_x, padding_y=padding_y) 

1463 

1464 self.width = width 

1465 self.height = None 

1466 self.range = scale_range 

1467 

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 

1474 

1475 self.tick_width = tick_width 

1476 self.line_width = line_width 

1477 

1478 self.vt_line_height = 10 

1479 

1480 def __name__(self): 

1481 return "ScaleFace" 

1482 

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): 

1492 

1493 if drawer.TYPE == 'circ' and abs(point[1]) >= pi/2: 

1494 pos = swap_pos(pos) 

1495 

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) 

1505 

1506 x, y, _, dy = box 

1507 zx, zy = self.zoom 

1508 

1509 self.viewport = (drawer.viewport.x, drawer.viewport.x + drawer.viewport.dx) 

1510 

1511 self.height = (self.line_width + 10 + self.max_fsize) / zy 

1512 

1513 height = min(dy, self.height) 

1514 

1515 if pos == "aligned_bottom": 

1516 y = y + dy - height 

1517 

1518 self._box = Box(x, y, self.width / zx, height) 

1519 return self._box 

1520 

1521 def draw(self, drawer): 

1522 x0, y, _, dy = self._box 

1523 zx, zy = self.zoom 

1524 

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}) 

1532 

1533 

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 

1537 

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 

1544 

1545 for i in range(sm_start, sm_end + 1): 

1546 x = x0 + i * dx 

1547 number = range_factor * i * dx 

1548 

1549 if number == 0: 

1550 text = "0" 

1551 else: 

1552 text = self.formatter % number if self.formatter else str(number) 

1553 

1554 text = text.rstrip('0').rstrip('.') if '.' in text else text 

1555 

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) 

1566 

1567 yield draw_text(text_box, text, style=text_style) 

1568 

1569 p1 = (x, y + dy - self.vt_line_height / zy) 

1570 p2 = (x, y + dy) 

1571 

1572 yield draw_line(p1, p2, style={'stroke-width': self.line_width, 

1573 'stroke': self.color}) 

1574 

1575 

1576class PieChartFace(CircleFace): 

1577 

1578 def __init__(self, radius, data, name="", 

1579 padding_x=0, padding_y=0, tooltip=None): 

1580 

1581 super().__init__(self, name=name, color=None, 

1582 padding_x=padding_x, padding_y=padding_y, tooltip=tooltip) 

1583 

1584 self.radius = radius 

1585 # Drawing private properties 

1586 self._max_radius = 0 

1587 self._center = (0, 0) 

1588 

1589 # data = [ [name, value, color, tooltip], ... ] 

1590 # self.data = [ (name, value, color, tooltip, a, da) ] 

1591 self.data = [] 

1592 self.compute_pie(list(data)) 

1593 

1594 def __name__(self): 

1595 return "PieChartFace" 

1596 

1597 def compute_pie(self, data): 

1598 total_value = sum(d[1] for d in data) 

1599 

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 

1605 

1606 assert a >= 2 * pi - 1e-5 and a <= 2 * pi + 1e-5, "Incorrect pie" 

1607 

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) 

1613 

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) 

1619 

1620 

1621class HTMLFace(RectFace): 

1622 def __init__(self, html, width, height, name="", padding_x=0, padding_y=0): 

1623 

1624 RectFace.__init__(self, width=width, height=height, 

1625 name=name, padding_x=padding_x, padding_y=padding_y) 

1626 

1627 self.content = html 

1628 

1629 def __name__(self): 

1630 return "HTMLFace" 

1631 

1632 def draw(self, drawer): 

1633 yield draw_html(self._box, self.content) 

1634 

1635 

1636class ImgFace(RectFace): 

1637 def __init__(self, img_path, width, height, name="", padding_x=0, padding_y=0): 

1638 

1639 RectFace.__init__(self, width=width, height=height, 

1640 name=name, padding_x=padding_x, padding_y=padding_y) 

1641 

1642 

1643 

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)) 

1650 

1651 self.content = f'data:image/{extension};base64,{img}' 

1652 

1653 self.stretch = False 

1654 

1655 def __name__(self): 

1656 return "ImgFace" 

1657 

1658 def draw(self, drawer): 

1659 yield draw_img(self._box, self.content) 

1660 

1661 

1662class LegendFace(Face): 

1663 

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): 

1669 

1670 Face.__init__(self, name=title, 

1671 padding_x=padding_x, padding_y=padding_y) 

1672 

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 

1679 

1680 def __name__(self): 

1681 return "LegendFace" 

1682 

1683 def draw(self, drawer): 

1684 self._check_own_variables() 

1685 

1686 style = {'fill': self.color, 'opacity': self.opacity} 

1687 

1688 x, y, dx, dy = self._box 

1689 zx, zy = self.zoom 

1690 

1691 entry_h = min(15 / zy, dy / (len(self.colormap.keys()) + 2)) 

1692 

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 } 

1699 

1700 yield draw_text(title_box, 

1701 self.title, 

1702 style=text_style) 

1703 

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 

1711 

1712 

1713class StackedBarFace(RectFace): 

1714 """Face to show a series of stacked bars.""" 

1715 

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. 

1720 

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 

1727 

1728 def __name__(self): 

1729 return "StackedBarFace" 

1730 

1731 def draw(self, drawer): 

1732 x0, y0, dx0, dy0 = self._box 

1733 

1734 total = sum(d[1] for d in self.data) 

1735 scale_factor = dx0 / (total or 1) # the "or 1" prevents dividing by 0 

1736 

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