• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cogent3 / cogent3 / 15572657767

10 Jun 2025 11:49PM UTC coverage: 90.701% (-0.01%) from 90.712%
15572657767

push

github

web-flow
Merge pull request #2350 from GavinHuttley/develop

DEV: use uv for github action and pin python 3.13.3

30024 of 33102 relevant lines covered (90.7%)

10.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.96
/src/cogent3/draw/dendrogram.py
1
import contextlib
12✔
2
import functools
12✔
3
from collections import defaultdict
12✔
4
from math import floor
12✔
5
from typing import NoReturn
12✔
6

7
import numpy as np
12✔
8

9
from cogent3.core.tree import PhyloNode
12✔
10
from cogent3.draw.drawable import Drawable
12✔
11
from cogent3.util.misc import extend_docstring_from
12✔
12
from cogent3.util.union_dict import UnionDict
12✔
13

14

15
class TreeGeometryBase(PhyloNode):
12✔
16
    """base class that computes geometric coordinates for display"""
17

18
    def __init__(self, tree=None, length_attr="length", *args, **kwargs) -> None:
12✔
19
        """
20
        Parameters
21
        ----------
22
        tree : either a PhyloNode or TreeNode instance
23
        length_attr : str
24
            name of the attribute to use for length, defaults to 'length'
25
        """
26
        if tree is not None:
12✔
27
            children = [type(self)(child, *args, **kwargs) for child in tree.children]
12✔
28
            PhyloNode.__init__(
12✔
29
                self,
30
                params=tree.params.copy(),
31
                children=children,
32
                name=tree.name,
33
            )
34
        else:
35
            PhyloNode.__init__(self, **kwargs)
12✔
36

37
        # TODO do we need to validate the length_attr key exists?
38
        self._length = length_attr
12✔
39
        self._node_space = 1.3
12✔
40
        self._start = None
12✔
41
        self._end = None
12✔
42
        self._y = None
12✔
43
        self._x = None
12✔
44
        self._tip_rank = None
12✔
45
        self._max_x = 0
12✔
46
        self._min_x = 0
12✔
47
        self._max_y = 0
12✔
48
        self._min_y = 0
12✔
49
        self._theta = 0
12✔
50
        self._num_tips = 1
12✔
51

52
    def propagate_properties(self) -> None:
12✔
53
        self._init_length_depth_attr()
12✔
54
        self._init_tip_ranks()
12✔
55

56
    def _max_child_depth(self):
12✔
57
        """computes the maximum number of nodes to the tip"""
58
        if self.is_tip():
12✔
59
            self.params["max_child_depth"] = self.params["depth"]
12✔
60
        else:
61
            depths = [child._max_child_depth() for child in self.children]
12✔
62
            self.params["max_child_depth"] = max(depths)
12✔
63
        return self.params["max_child_depth"]
12✔
64

65
    def _init_tip_ranks(self) -> None:
12✔
66
        tips = self.tips()
12✔
67
        num_tips = len(tips)
12✔
68
        for index, tip in enumerate(tips):
12✔
69
            tip._tip_rank = index
12✔
70
            tip._y = ((num_tips - 1) / 2 - index) * self.node_space
12✔
71

72
    def _init_length_depth_attr(self) -> None:
12✔
73
        """check it exists, if not, creates with default value of 1"""
74
        # we compute cumulative lengths first with sorting, as that dictates the ordering of children
75
        # then determin
76
        for edge in self.preorder():
12✔
77
            # making sure children have the correct setting for ._length
78
            edge._length = self._length
12✔
79
            edge._num_tips = self._num_tips
12✔
80
            edge.node_space = self.node_space
12✔
81
            if len(edge.children) > 0:
12✔
82
                # we order children by their length
83
                children = sorted(
12✔
84
                    [(c.params.get(self._length, 0) or 0, c) for c in edge.children],
85
                )
86
                edge.children = [c for _, c in children]
12✔
87

88
            if edge.is_root():
12✔
89
                edge.params["depth"] = 0
12✔
90
                edge.params[self._length] = 0
12✔
91
                continue
12✔
92

93
            length = edge.params.get(self._length, None) or 1
12✔
94
            edge.params[self._length] = length
12✔
95
            edge.params["depth"] = edge.parent.params.get("depth", 0) + 1
12✔
96

97
        self._max_child_depth()
12✔
98
        for edge in self.preorder():
12✔
99
            if edge.is_root():
12✔
100
                edge.params["cum_length"] = 0
12✔
101
                continue
12✔
102

103
            parent_frac = edge.parent.params.get("cum_length", 0)
12✔
104
            if edge.is_tip():
12✔
105
                frac = 1 - parent_frac
12✔
106
            else:
107
                frac = 1 / edge.params["max_child_depth"]
12✔
108
            edge.params["frac_pos"] = frac
12✔
109
            edge.params["cum_length"] = parent_frac + edge.params[self._length]
12✔
110

111
    @property
12✔
112
    def x(self):
12✔
113
        if self.is_root():
12✔
114
            self._x = 0
12✔
115
        elif self._x is None:
12✔
116
            val = self.params["cum_length"]
12✔
117
            self._x = val
12✔
118
        return self._x
12✔
119

120
    @property
12✔
121
    def max_x(self):
12✔
122
        if not self._max_x:
12✔
123
            self._max_x = max(e.x for e in self.tips())
12✔
124
        return self._max_x
12✔
125

126
    @property
12✔
127
    def min_x(self):
12✔
128
        if not self._min_x:
12✔
129
            self._min_x = min([e.x for e in self.postorder()])
12✔
130
        return self._min_x
12✔
131

132
    @property
12✔
133
    def max_y(self):
12✔
134
        if not self._max_y:
12✔
135
            self._max_y = max(e.y for e in self.tips())
12✔
136
        return self._max_y
12✔
137

138
    @property
12✔
139
    def min_y(self):
12✔
140
        if not self._min_y:
12✔
141
            self._min_y = min(e.y for e in self.tips())
12✔
142
        return self._min_y
12✔
143

144
    @property
12✔
145
    def node_space(self):
12✔
146
        return self._node_space
12✔
147

148
    @node_space.setter
12✔
149
    def node_space(self, value) -> None:
12✔
150
        if value < 0:
12✔
151
            msg = "node spacing must be > 0"
×
152
            raise ValueError(msg)
×
153
        self._node_space = value
12✔
154

155
    @property
12✔
156
    def tip_rank(self):
12✔
157
        return self._tip_rank
12✔
158

159
    @property
12✔
160
    def theta(self):
12✔
161
        return self._theta
12✔
162

163
    @property
12✔
164
    def start(self):
12✔
165
        """x, y coordinate for line connecting parent to this node"""
166
        # needs to ask parent, but has to do more than just get the parent
167
        # end, parent needs to know
168
        return (0, self.y) if self.is_root() else (self.parent.x, self.y)
12✔
169

170
    @property
12✔
171
    def end(self):
12✔
172
        """x, y coordinate for this node"""
173
        return self.x, self.y
12✔
174

175
    @property
12✔
176
    def depth(self):
12✔
177
        return self.params["depth"]
×
178

179
    def get_segment_to_parent(self):
12✔
180
        """returns (self.start, self.end)"""
181
        if self.is_root():
12✔
182
            return ((None, None),)
12✔
183

184
        segment_start = self.parent.get_segment_to_child(self)
12✔
185
        if isinstance(segment_start, list):
12✔
186
            result = [*segment_start, (None, None), self.start, self.end]
12✔
187
        else:
188
            result = segment_start, self.start, (None, None), self.start, self.end
12✔
189
        return tuple(result)
12✔
190

191
    def get_segment_to_child(self, child):
12✔
192
        """returns coordinates connecting a child to self and descendants"""
193
        return self.end
×
194

195
    def value_and_coordinate(
12✔
196
        self, attr: str, padding: float = 0.1, max_attr_length: int | None = None
197
    ) -> NoReturn:
198
        """
199
        Parameters
200
        ----------
201
        attr
202
            attribute of self, e.g. 'name', or key in self.params
203
        padding
204
            distance from self coordinate
205
        max_attr_length
206
            maximum text length of the attribute
207
        Returns
208
        -------
209
        (value of attr, (x, y)
210
        """
211
        # TODO, possibly also return a rotation?
212
        msg = "implement in sub-class"
×
213
        raise NotImplementedError(msg)
×
214

215
    def support_text_coord(
12✔
216
        self,
217
        xshift: int,
218
        yshift: int,
219
        threshold: float = 100,
220
        max_attr_length: int | None = 4,
221
    ):
222
        """
223
        Parameters
224
        ----------
225
        xshift, yshift
226
            relative position (in pixels) of text
227
        threshold
228
            support values below this will be displayed
229
        max_attr_length
230
            maximum text length of the attribute
231

232
        Returns
233
        -------
234
        None if threshold not met, else params['support'] and coords
235
        """
236
        if xshift is None:
12✔
237
            xshift = -14
12✔
238

239
        if yshift is None:
12✔
240
            yshift = 7
12✔
241

242
        if self.is_tip():
12✔
243
            return None
12✔
244

245
        val = self.params.get("support", None)
12✔
246
        if val is None or val > threshold or self.is_tip():
12✔
247
            return None
12✔
248

249
        if threshold > 1:
12✔
250
            # assuming we have support as a percentage
251
            text = f"{int(round(val, 0))}"
12✔
252
        else:
253
            text = f"{val:.2f}"
×
254

255
        x = self.x
12✔
256
        return UnionDict(
12✔
257
            x=x,
258
            y=self.y,
259
            xshift=xshift,
260
            yshift=yshift,
261
            textangle=self.theta,
262
            showarrow=False,
263
            text=text,
264
            xanchor="center",
265
        )
266

267

268
class SquareTreeGeometry(TreeGeometryBase):
12✔
269
    """represents Square dendrograms, contemporaneous or not"""
270

271
    def __init__(self, *args, **kwargs) -> None:
12✔
272
        super().__init__(*args, **kwargs)
12✔
273

274
    @property
12✔
275
    def y(self):
12✔
276
        if self._y is None:
12✔
277
            num_kids = len(self.children)
12✔
278
            even = num_kids % 2 == 0
12✔
279
            i = floor(num_kids / 2)
12✔
280
            if even:
12✔
281
                val = (self.children[i].y + self.children[i - 1].y) / 2
12✔
282
            else:
283
                val = self.children[i].y
12✔
284
            self._y = val
12✔
285
        return self._y
12✔
286

287
    def get_segment_to_child(self, child):
12✔
288
        """returns coordinates connecting a child to self and descendants"""
289

290
        if not hasattr(self, "_ordered"):
12✔
291
            self._ordered = sorted(
12✔
292
                [(c.y, c.start) for c in self.children] + [(self.y, self.end)],
293
            )
294
        ordered = self._ordered
12✔
295
        dist = child.y - self.y
12✔
296
        if np.allclose(dist, 0):
12✔
297
            return self.end
12✔
298
        if dist < 0:
12✔
299
            result = ordered[ordered.index((child.y, child.start)) + 1][1]
12✔
300
        else:
301
            result = ordered[ordered.index((child.y, child.start)) - 1][1]
12✔
302

303
        return result
12✔
304

305
    @extend_docstring_from(TreeGeometryBase.value_and_coordinate)
12✔
306
    def value_and_coordinate(
12✔
307
        self,
308
        attr: str = "name",
309
        padding: float = 0.05,
310
        max_attr_length: int | None = None,
311
    ):
312
        # TODO, possibly also return a rotation?
313
        x = self.x + padding
12✔
314
        y = self.y
12✔
315
        value = self.params.get(attr, None)
12✔
316
        if value is None:
12✔
317
            value = getattr(self, attr, None)
12✔
318
        return UnionDict(
12✔
319
            x=x,
320
            y=y,
321
            textangle=self.theta,
322
            showarrow=False,
323
            text=value,
324
            xanchor="left",
325
        )
326

327

328
class _AngularGeometry:
12✔
329
    """directly connects child to parents"""
330

331
    @property
12✔
332
    def start(self):
12✔
333
        """x, y coordinate for line connecting parent to this node"""
334
        return (0, self.y) if self.is_root() else self.parent.end
12✔
335

336

337
class AngularTreeGeometry(_AngularGeometry, SquareTreeGeometry):
12✔
338
    def __init__(self, *args, **kwargs) -> None:
12✔
339
        super().__init__(*args, **kwargs)
12✔
340

341

342
r_2_d = np.pi / 180
12✔
343

344

345
def polar_2_cartesian(θ, radius):
12✔
346
    radians = θ * r_2_d
12✔
347
    x = np.cos(radians) * radius
12✔
348
    y = np.sin(radians) * radius
12✔
349
    return x, y
12✔
350

351

352
class CircularTreeGeometry(TreeGeometryBase):
12✔
353
    def __init__(self, *args, **kwargs) -> None:
12✔
354
        super().__init__(*args, **kwargs)
12✔
355
        self._num_tips = 1
12✔
356
        self._theta = None
12✔
357
        self._node_space = None
12✔
358

359
    def propagate_properties(self) -> None:
12✔
360
        self._num_tips = len(self.tips())
12✔
361
        self._init_length_depth_attr()
12✔
362
        self._init_tip_ranks()
12✔
363

364
    @property
12✔
365
    def node_space(self):
12✔
366
        if self._node_space is None:
12✔
367
            self._node_space = 360 / self._num_tips
12✔
368
        return self._node_space
12✔
369

370
    @node_space.setter
12✔
371
    def node_space(self, value) -> None:
12✔
372
        if value < 0:
12✔
373
            msg = "node spacing must be > 0"
×
374
            raise ValueError(msg)
×
375
        if self._num_tips * value > 360:
12✔
376
            msg = f"{value} * {(self._num_tips + 1)} is > 360"
×
377
            raise ValueError(msg)
×
378
        self._node_space = value
12✔
379

380
    @property
12✔
381
    def theta(self):
12✔
382
        if self._theta is None:
12✔
383
            if self.is_root():
12✔
384
                self._theta = 0
12✔
385
            elif self.is_tip():
12✔
386
                self._theta = (self.tip_rank + 1) * self.node_space
12✔
387
            else:
388
                self._theta = sum(c.theta for c in self.children) / len(self.children)
12✔
389

390
        return self._theta
12✔
391

392
    @property
12✔
393
    def x(self):
12✔
394
        if self._x is None:
12✔
395
            _ = self.y  # triggers populating values
12✔
396
        return self._x
12✔
397

398
    @property
12✔
399
    def y(self):
12✔
400
        radius = self.params["cum_length"]
12✔
401
        if self._y is None and self.is_root():
12✔
402
            self._x = self._y = 0
12✔
403
        elif self._x is None or self._y is None:
12✔
404
            x, y = polar_2_cartesian(self.theta, radius)
12✔
405
            self._x = x
12✔
406
            self._y = y
12✔
407

408
        return self._y
12✔
409

410
    @property
12✔
411
    def start(self):
12✔
412
        """x, y coordinate for line connecting parent to this node"""
413
        # needs to ask parent, but has to do more than just get the parent
414
        # end, parent needs to know
415
        if self.is_root():
12✔
416
            val = 0, 0
×
417
        else:
418
            # radius comes from parent
419
            radius = self.parent.params["cum_length"]
12✔
420
            val = polar_2_cartesian(self.theta, radius)
12✔
421
        return val
12✔
422

423
    @extend_docstring_from(TreeGeometryBase.value_and_coordinate)
12✔
424
    def value_and_coordinate(
12✔
425
        self,
426
        attr: str = "name",
427
        padding: float = 0.05,
428
        max_attr_length: int | None = None,
429
    ) -> UnionDict:
430
        value = self.params.get(attr, None)
12✔
431
        if value is None:
12✔
432
            value = getattr(self, attr, None)
12✔
433

434
        max_attr_length = len(value) if max_attr_length is None else max_attr_length
12✔
435
        if 90 < self.theta <= 270:
12✔
436
            textangle = 180 - self.theta
12✔
437
            value = value.rjust(max_attr_length)
12✔
438
        else:
439
            textangle = 360 - self.theta
12✔
440
            value = value.ljust(max_attr_length)
12✔
441

442
        radius = np.sqrt(self.x**2 + self.y**2) + padding
12✔
443
        x, y = polar_2_cartesian(self.theta, radius)
12✔
444

445
        return UnionDict(
12✔
446
            x=x,
447
            y=y,
448
            textangle=textangle,
449
            showarrow=False,
450
            text=value,
451
            xanchor="center",
452
            yanchor="middle",
453
        )
454

455
    @extend_docstring_from(TreeGeometryBase.support_text_coord)
12✔
456
    def support_text_coord(
12✔
457
        self,
458
        xshift: float,
459
        yshift: float,
460
        threshold: float = 100,
461
        max_attr_length: int | None = 4,
462
    ):
463
        if xshift is None:
×
464
            xshift = -18
×
465

466
        if yshift is None:
×
467
            yshift = 3
×
468

469
        if self.is_tip():
×
470
            return None
×
471

472
        val = self.params.get("support", None)
×
473
        if val is None or val > threshold or self.is_tip():
×
474
            return None
×
475

476
        if threshold > 1:
×
477
            # assuming we have support as a percentage
478
            text = f"{int(round(val, 0))}"
×
479
        else:
480
            text = f"{val:.2f}"
×
481

482
        print(f"{threshold=}, {val=}")
×
483

484
        if 90 < self.theta <= 270:
×
485
            textangle = 180 - self.theta
×
486
        else:
487
            textangle = 360 - self.theta
×
488
            xshift = -xshift
×
489

490
        c = np.cos(textangle * (np.pi / 180))
×
491
        s = np.sin(textangle * (np.pi / 180))
×
492
        m = np.array([[c, s], [-s, c]])
×
493
        d = np.dot(m, [xshift, yshift])
×
494

495
        new_xshift = float(d.T[0])
×
496
        new_yshift = float(d.T[1])
×
497

498
        x = self.x
×
499
        return UnionDict(
×
500
            x=x,
501
            y=self.y,
502
            xshift=new_xshift,
503
            yshift=new_yshift,
504
            textangle=textangle,
505
            showarrow=False,
506
            text=text,
507
            xanchor="center",
508
        )
509

510
    def get_segment_to_child(self, child):
12✔
511
        """returns coordinates connecting a child to self and descendants"""
512

513
        if not hasattr(self, "_ordered"):
12✔
514
            self._ordered = sorted(
12✔
515
                [(c.theta, c.start) for c in self.children] + [(self.theta, self.end)],
516
            )
517
        ordered = self._ordered
12✔
518
        dist = child.theta - self.theta
12✔
519
        if np.allclose(dist, 0):
12✔
520
            return self.end
×
521
        if dist < 0:
12✔
522
            neighbours = [
12✔
523
                ordered[ordered.index((child.theta, child.start)) + 1],
524
                (child.theta, child.start),
525
            ]
526
        else:
527
            neighbours = [
12✔
528
                ordered[ordered.index((child.theta, child.start)) - 1],
529
                (child.theta, child.start),
530
            ]
531

532
        neighbours = sorted(neighbours)
12✔
533
        result = [
12✔
534
            polar_2_cartesian(theta, self.params["cum_length"])
535
            for theta in np.arange(neighbours[0][0], neighbours[1][0], 5)
536
        ]
537
        result.append(neighbours[1][1])
12✔
538

539
        return result
12✔
540

541

542
class RadialTreeGeometry(_AngularGeometry, CircularTreeGeometry):
12✔
543
    def __init__(self, *args, **kwargs) -> None:
12✔
544
        super().__init__(*args, **kwargs)
12✔
545

546
    def get_segment_to_child(self, child):
12✔
547
        """returns coordinates connecting a child to self and descendants"""
548
        return self.end
12✔
549

550

551
class Dendrogram(Drawable):
12✔
552
    def __init__(
12✔
553
        self,
554
        tree: "TreeGeometryBase",
555
        style: str = "square",
556
        label_pad: int | None = None,
557
        contemporaneous: bool = False,
558
        show_support: bool = True,
559
        threshold: float = 1.0,
560
        support_is_percent: bool = True,
561
        *args,
562
        **kwargs,
563
    ) -> None:
564
        length_attr = kwargs.pop("length_attr", None)
12✔
565
        super().__init__(
12✔
566
            *args,
567
            visible_axes=False,
568
            showlegend=False,
569
            **kwargs,
570
        )
571
        klass = {
12✔
572
            "square": SquareTreeGeometry,
573
            "circular": CircularTreeGeometry,
574
            "angular": AngularTreeGeometry,
575
            "radial": RadialTreeGeometry,
576
        }[style]
577
        if length_attr is None and not contemporaneous:
12✔
578
            contemporaneous = tree.children[0].length is None
12✔
579

580
        length_attr = "frac_pos" if contemporaneous else length_attr or "length"
12✔
581
        kwargs = UnionDict(length_attr=length_attr) if contemporaneous else {}
12✔
582
        self.tree = klass(tree, **kwargs)
12✔
583
        self.tree.propagate_properties()
12✔
584
        self._label_pad = label_pad
12✔
585
        self._tip_font = UnionDict(size=12, family="Inconsolata, monospace")
12✔
586
        self._line_width = 1.25
12✔
587
        self._marker_size = 3
12✔
588
        self._line_color = "black"
12✔
589
        self._scale_bar = "bottom left"
12✔
590
        self._edge_sets = {}
12✔
591
        self._edge_mapping = {}
12✔
592
        self._contemporaneous = contemporaneous
12✔
593
        self._tips_as_text = True
12✔
594
        self._length_attr = self.tree._length
12✔
595
        self._tip_names = tuple(e.name for e in self.tree.tips())
12✔
596
        self._max_label_length = max(map(len, self._tip_names))
12✔
597
        if all("support" not in child.params for child in self.tree.children):
12✔
598
            show_support = False
12✔
599
        self._show_support = show_support
12✔
600

601
        if support_is_percent and threshold <= 1.0:
12✔
602
            threshold *= 100
12✔
603

604
        self._threshold = threshold
12✔
605
        self._support_xshift = None
12✔
606
        self._support_yshift = None
12✔
607
        self._default_layout.autosize = True
12✔
608
        self.layout = UnionDict(self._default_layout)
12✔
609

610
    @property
12✔
611
    def label_pad(self):
12✔
612
        default = 0.15 if isinstance(self.tree, CircularTreeGeometry) else 0.025
12✔
613
        if self._label_pad is None:
12✔
614
            if not self.contemporaneous:
12✔
615
                max_x = max(self.tree.max_x, abs(self.tree.min_x))
12✔
616
                self._label_pad = max_x * default
12✔
617
            else:
618
                self._label_pad = default
×
619
        return self._label_pad
12✔
620

621
    @label_pad.setter
12✔
622
    def label_pad(self, value) -> None:
12✔
623
        self._label_pad = value
×
624
        self._traces = []
×
625

626
    @property
12✔
627
    def support_xshift(self) -> int:
12✔
628
        """relative x position (in pixels) of support text. Can be negative or positive."""
629
        return self._support_xshift
12✔
630

631
    @support_xshift.setter
12✔
632
    def support_xshift(self, value: int) -> None:
12✔
633
        if value == self._support_xshift:
×
634
            return
×
635
        self._support_xshift = value
×
636
        self._traces = []
×
637

638
    @property
12✔
639
    def support_yshift(self) -> int:
12✔
640
        """relative y position (in pixels) of support text. Can be negative or positive."""
641
        return self._support_yshift
12✔
642

643
    @support_yshift.setter
12✔
644
    def support_yshift(self, value: int) -> None:
12✔
645
        if value == self._support_yshift:
×
646
            return
×
647
        self._support_yshift = value
×
648
        self._traces = []
×
649

650
    @property
12✔
651
    def contemporaneous(self) -> bool:
12✔
652
        return self._contemporaneous
12✔
653

654
    @contemporaneous.setter
12✔
655
    def contemporaneous(self, value: bool) -> None:
12✔
656
        if type(value) != bool:
×
657
            raise TypeError
×
658
        if self._contemporaneous != value:
×
659
            klass = self.tree.__class__
×
660
            length_attr = "frac_pos" if value else self._length_attr
×
661
            self.tree = klass(self.tree, length_attr=length_attr)
×
662
            self.tree.propagate_properties()
×
663
            self._traces = []
×
664
            self.layout.xaxis |= {"range": None, "autorange": True}
×
665
            self.layout.yaxis |= {"range": None, "autorange": True}
×
666
            if value:  # scale bar not needed
×
667
                self._scale_bar = False
×
668

669
        self._contemporaneous = value
×
670

671
    @functools.singledispatchmethod
12✔
672
    def _update_tip_font(self, val) -> NoReturn:
12✔
673
        """update tip font settings"""
674
        msg = f"{type(val)} not a supported type for tip_font"
×
675
        raise TypeError(msg)
×
676

677
    @_update_tip_font.register
12✔
678
    def _(self, val: dict) -> None:
12✔
679
        self._tip_font |= val
12✔
680

681
    @_update_tip_font.register
12✔
682
    def _(self, val: int) -> None:
12✔
683
        self._tip_font.size = val
×
684

685
    @property
12✔
686
    def tip_font(self):
12✔
687
        return self._tip_font
12✔
688

689
    @tip_font.setter
12✔
690
    def tip_font(self, val) -> None:
12✔
691
        """update tip font settings"""
692
        self._update_tip_font(val)
12✔
693

694
    def _scale_label_pad(self):
12✔
695
        """returns the label pad scaled by maximum dist to tip"""
696
        return self.label_pad
×
697

698
    def _get_tip_name_annotations(self):
12✔
699
        annotations = []
12✔
700
        for tip in self.tree.tips():
12✔
701
            anote = tip.value_and_coordinate(
12✔
702
                "name",
703
                padding=self.label_pad,
704
                max_attr_length=self._max_label_length,
705
            )
706
            anote |= UnionDict(xref="x", yref="y", font=self.tip_font)
12✔
707
            annotations.append(anote)
12✔
708
        return annotations
12✔
709

710
    def _get_scale_bar(self):
12✔
711
        if not self.scale_bar or self.contemporaneous:
12✔
712
            return None, None
12✔
713

714
        # place scale bar above / below dendrogram area
715
        y_shift = (self.tree.max_y - self.tree.min_y) / 11
12✔
716
        x = self.tree.min_x if "left" in self.scale_bar else self.tree.max_x
12✔
717
        y = (
12✔
718
            self.tree.min_y - y_shift
719
            if "bottom" in self.scale_bar
720
            else self.tree.max_y + y_shift
721
        )
722
        scale = 0.1 * self.tree.max_x
12✔
723
        text = f"{scale:.1e}" if scale < 1e-2 else f"{scale:.2f}"
12✔
724
        shape = {
12✔
725
            "type": "line",
726
            "x0": x,
727
            "y0": y,
728
            "x1": x + scale,
729
            "y1": y,
730
            "line": {"color": self._line_color, "width": self._line_width},
731
            "name": "scale_bar",
732
        }
733
        annotation = UnionDict(
12✔
734
            x=x + (0.5 * scale),
735
            y=y,
736
            xref="x",
737
            yref="y",
738
            yshift=10,
739
            text=text,
740
            showarrow=False,
741
            ax=0,
742
            ay=0,
743
        )
744
        return shape, annotation
12✔
745

746
    def _build_fig(self, **kwargs) -> None:
12✔
747
        grouped = {}
12✔
748

749
        tree = self.tree
12✔
750
        text = UnionDict(
12✔
751
            {
752
                "type": "scatter",
753
                "text": [],
754
                "x": [],
755
                "y": [],
756
                "hoverinfo": "text",
757
                "mode": "markers",
758
                "marker": {
759
                    "symbol": "circle",
760
                    "color": "black",
761
                    "size": self._marker_size,
762
                },
763
                "showlegend": False,
764
            },
765
        )
766
        support_text = []
12✔
767
        get_edge_group = self._edge_mapping.get
12✔
768
        for edge in tree.preorder():
12✔
769
            key = get_edge_group(edge.name, None)
12✔
770
            if key not in grouped:
12✔
771
                grouped[key] = defaultdict(list)
12✔
772
            group = grouped[key]
12✔
773
            coords = edge.get_segment_to_parent()
12✔
774
            xs, ys = list(zip(*coords, strict=False))
12✔
775
            group["x"].extend((*xs, None))
12✔
776
            group["y"].extend((*ys, None))
12✔
777

778
            edge_label = edge.value_and_coordinate("name", padding=0)
12✔
779
            text["x"].append(edge_label.x)
12✔
780
            text["y"].append(edge_label.y)
12✔
781
            text["text"].append(edge_label.text)
12✔
782
            if self.show_support:
12✔
783
                support = edge.support_text_coord(
12✔
784
                    self.support_xshift,
785
                    self.support_yshift,
786
                    threshold=self.support_threshold,
787
                )
788
                if support is not None:
12✔
789
                    support |= UnionDict(xref="x", yref="y", font=self.tip_font)
12✔
790
                    support_text.append(support)
12✔
791

792
        traces = []
12✔
793
        for key in grouped:
12✔
794
            group = grouped[key]
12✔
795
            style = self._edge_sets.get(
12✔
796
                key,
797
                UnionDict(
798
                    line=UnionDict(
799
                        width=self._line_width,
800
                        color=self._line_color,
801
                        shape="spline",
802
                        smoothing=1.3,
803
                    ),
804
                ),
805
            )
806
            trace = UnionDict(type="scatter", x=group["x"], y=group["y"], mode="lines")
12✔
807
            trace |= style
12✔
808
            if "legendgroup" not in style:
12✔
809
                trace["showlegend"] = False
12✔
810
            else:
811
                trace["name"] = style["legendgroup"]
×
812
            traces.append(trace)
12✔
813

814
        scale_shape, scale_text = self._get_scale_bar()
12✔
815
        traces.extend([text])
12✔
816
        self.traces.extend(traces)
12✔
817
        if self.tips_as_text:
12✔
818
            self.layout.annotations = tuple(self._get_tip_name_annotations())
12✔
819

820
        if self.show_support and support_text:
12✔
821
            self.layout.annotations = self.layout.annotations + tuple(support_text)
12✔
822

823
        if scale_shape:
12✔
824
            self.layout.shapes = [*self.layout.get("shape", []), scale_shape]
12✔
825
            self.layout.annotations += (scale_text,)
12✔
826
        else:
827
            self.layout.pop("shapes", None)
12✔
828

829
        if isinstance(self.tree, CircularTreeGeometry):
12✔
830
            # must draw this square
831
            if self.layout.width and self.layout.height:
12✔
832
                dim = max(self.layout.width, self.layout.height)
12✔
833
            elif self.layout.width:
12✔
834
                dim = self.layout.width
×
835
            elif self.layout.height:
12✔
836
                dim = self.layout.height
×
837
            else:
838
                dim = 800
12✔
839
            self.layout.width = self.layout.height = dim
12✔
840

841
            # Span of tree along x-axis and Span of tree along y-axis
842
            x_diff = self.tree.max_x - self.tree.min_x
12✔
843
            y_diff = self.tree.max_y - self.tree.min_y
12✔
844

845
            # Maximum span
846
            max_span = max(x_diff, y_diff)
12✔
847

848
            # Use maximum span along both axes and pad the smaller one accordingly
849
            axes_range = {
12✔
850
                "xaxis": {
851
                    "range": [
852
                        self.tree.min_x - (1.4 * max_span - x_diff) / 2,
853
                        self.tree.max_x + (1.4 * max_span - x_diff) / 2,
854
                    ],
855
                },
856
                "yaxis": {
857
                    "range": [
858
                        self.tree.min_y - (1.4 * max_span - y_diff) / 2,
859
                        self.tree.max_y + (1.4 * max_span - y_diff) / 2,
860
                    ],
861
                },
862
            }
863
            self.layout |= axes_range
12✔
864

865
    def style_edges(self, edges, line, legendgroup=None, tip2=None, **kwargs) -> None:
12✔
866
        """adjust display layout for the edges
867

868
        Parameters
869
        ----------
870
        edges : str or series
871
            names of edges
872
        line : dict
873
            with plotly line style to applied to these edges
874
        legendgroup : str or None
875
            if str, a legend will be presented
876
        tip2 : str
877
            if provided, and edges is a str, passes edges (as tip1) and kwargs to get_edge_names
878
        kwargs
879
            keyword arguments passed onto get_edge_names
880
        """
881
        if tip2:
12✔
882
            assert type(edges) == str, "cannot use a series of edges and tip2"
×
883
            edges = self.get_edge_names(edges, tip2, **kwargs)
×
884

885
        if type(edges) == str:
12✔
886
            edges = [edges]
12✔
887
        edges = frozenset(edges)
12✔
888
        if not edges.issubset({edge.name for edge in self.tree.preorder()}):
12✔
889
            msg = "edge not present in tree"
12✔
890
            raise ValueError(msg)
12✔
891
        style = UnionDict(width=self._line_width, color=self._line_color)
12✔
892
        style.update(line)
12✔
893
        self._edge_sets[edges] = UnionDict(legendgroup=legendgroup, line=style)
12✔
894
        mapping = dict.fromkeys(edges, edges)
12✔
895
        self._edge_mapping.update(mapping)
12✔
896
        if legendgroup:
12✔
897
            self.layout["showlegend"] = True
×
898

899
        # need to trigger recreation of figure
900
        self._traces = []
12✔
901

902
    def reorient(self, name, tip2=None, **kwargs) -> None:
12✔
903
        """change orientation of tree
904
        Parameters
905
        ----------
906
        name : str
907
            name of an edge in the tree. If name is a tip, its parent becomes
908
            the new root, otherwise the edge becomes the root.
909
        tip2 : str
910
            if provided, passes name (as tip1) and all other args to get_edge_names,
911
            but sets clade=False and stem=True
912
        kwargs
913
            keyword arguments passed onto get_edge_names
914
        """
915
        if tip2:
×
916
            kwargs.update({"stem": True, "clade": False})
×
917
            edges = self.get_edge_names(name, tip2, **kwargs)
×
918
            name = edges[0]
×
919

920
        if name in self._tip_names:
×
921
            self.tree = self.tree.rooted_with_tip(name)
×
922
        else:
923
            self.tree = self.tree.rooted_at(name)
×
924

925
        self.tree.propagate_properties()
×
926
        self._traces = []
×
927

928
    def get_edge_names(self, tip1, tip2, outgroup=None, stem=False, clade=True):
12✔
929
        """
930

931
        Parameters
932
        ----------
933
        tip1 : str
934
            name of tip 1
935
        tip2 : str
936
            name of tip 1
937
        outgroup : str
938
            name of tip outside clade of interest
939
        stem : bool
940
            include name of stem to clade defined by tip1, tip2, outgroup
941
        clade : bool
942
            include names of edges within clade defined by tip1, tip2, outgroup
943

944
        Returns
945
        -------
946
        list of edge names
947
        """
948
        return self.tree.get_edge_names(
12✔
949
            tip1,
950
            tip2,
951
            stem=stem,
952
            clade=clade,
953
            outgroup_name=outgroup,
954
        )
955

956
    @property
12✔
957
    def scale_bar(self):
12✔
958
        """where to place a scale bar"""
959
        return self._scale_bar
12✔
960

961
    @scale_bar.setter
12✔
962
    def scale_bar(self, value) -> None:
12✔
963
        if value is True:
12✔
964
            value = "bottom left"
×
965

966
        valid = {"bottom left", "bottom right", "top left", "top right", False, None}
12✔
967

968
        assert value in valid
12✔
969
        if value != self._scale_bar:
12✔
970
            self._traces = []
12✔
971
        self._scale_bar = value
12✔
972

973
    @property
12✔
974
    def tips_as_text(self):
12✔
975
        """displays tips as text"""
976
        return self._tips_as_text
12✔
977

978
    @tips_as_text.setter
12✔
979
    def tips_as_text(self, value) -> None:
12✔
980
        assert type(value) is bool
×
981
        if value == self._tips_as_text:
×
982
            return
×
983

984
        self._tips_as_text = value
×
985
        self._traces = []
×
986
        self.layout.annotations = ()
×
987

988
    @property
12✔
989
    def line_width(self):
12✔
990
        """width of dendrogram lines"""
991
        return self._line_width
×
992

993
    @line_width.setter
12✔
994
    def line_width(self, width) -> None:
12✔
995
        self._line_width = width
×
996
        if self.traces:
×
997
            setting = {"width": width}
×
998
            for trace in self.traces:
×
999
                with contextlib.suppress(KeyError):
×
1000
                    trace["line"] |= setting
×
1001

1002
    @property
12✔
1003
    def marker(self):
12✔
1004
        return self._marker_size
×
1005

1006
    @marker.setter
12✔
1007
    def marker(self, size) -> None:
12✔
1008
        self._marker_size = size
×
1009
        if self.traces:
×
1010
            setting = {"size": size}
×
1011
            for trace in self.traces:
×
1012
                if trace.get("mode", None) == "markers":
×
1013
                    trace["marker"] |= setting
×
1014

1015
    @property
12✔
1016
    def show_support(self):
12✔
1017
        """whether tree edge support entries are displayed"""
1018
        return self._show_support
12✔
1019

1020
    @show_support.setter
12✔
1021
    def show_support(self, value) -> None:
12✔
1022
        """whether tree edge support entries are displayed"""
1023
        assert type(value) is bool
×
1024
        if value == self._show_support:
×
1025
            return
×
1026

1027
        self._show_support = value
×
1028
        self._traces = []
×
1029
        self.layout.annotations = ()
×
1030

1031
    @property
12✔
1032
    def support_threshold(self) -> float:
12✔
1033
        """cutoff for dislaying support"""
1034
        return self._threshold
12✔
1035

1036
    @support_threshold.setter
12✔
1037
    def support_threshold(self, value: float) -> None:
12✔
1038
        self._threshold = value
×
1039
        self._traces = []
×
1040
        self.layout.annotations = ()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc