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

rmcar17 / cogent3 / 16667762705

01 Aug 2025 06:10AM UTC coverage: 88.174% (-0.03%) from 88.204%
16667762705

push

github

rmcar17
MAINT: Use attributes for tree length and support, and callback for merging params with prune, removed unused semi-private attributes

51 of 61 new or added lines in 8 files covered. (83.61%)

6 existing lines in 3 files now uncovered.

29062 of 32960 relevant lines covered (88.17%)

5.29 hits per line

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

79.38
/src/cogent3/draw/dendrogram.py
1
import contextlib
6✔
2
from collections import defaultdict
6✔
3
from collections.abc import Iterable, Sequence
6✔
4
from math import floor
6✔
5
from typing import Any, Literal, cast
6✔
6

7
import numpy as np
6✔
8
from typing_extensions import Self
6✔
9

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

15

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

19
    def __init__(
6✔
20
        self,
21
        tree: PhyloNode | None = None,
22
        length_attr: str = "length",
23
        *args: Any,
24
        **kwargs: Any,
25
    ) -> None:
26
        """
27
        Parameters
28
        ----------
29
        tree : PhyloNode
30
        length_attr : str
31
            name of the attribute to use for length, defaults to 'length'
32
        """
33
        if tree is not None:
6✔
34
            children = [type(self)(child, *args, **kwargs) for child in tree.children]
6✔
35
            PhyloNode.__init__(
6✔
36
                self,
37
                params=tree.params.copy(),
38
                children=children,
39
                name=tree.name,
40
                name_loaded=tree.name_loaded,
41
                length=tree.length,
42
                support=tree.support,
43
            )
44
        else:
45
            PhyloNode.__init__(self, **kwargs)
6✔
46

47
        if length_attr == "length":
6✔
48
            self.params["length"] = self.length
6✔
49

50
        # TODO do we need to validate the length_attr key exists?
51
        self._length = length_attr
6✔
52
        self._node_space: float | None = 1.3
6✔
53
        self._start = None
6✔
54
        self._end = None
6✔
55
        self._y: float | None = None
6✔
56
        self._x: float | None = None
6✔
57
        self._tip_rank: int | None = None
6✔
58
        self._max_x = 0.0
6✔
59
        self._min_x = 0.0
6✔
60
        self._max_y = 0.0
6✔
61
        self._min_y = 0.0
6✔
62
        self._theta: float | None = 0.0
6✔
63
        self._num_tips = 1
6✔
64

65
    def propagate_properties(self) -> None:
6✔
66
        self._init_length_depth_attr()
6✔
67
        self._init_tip_ranks()
6✔
68

69
    def _max_child_depth(self) -> int:
6✔
70
        """computes the maximum number of nodes to the tip"""
71
        if self.is_tip():
6✔
72
            self.params["max_child_depth"] = self.params["depth"]
6✔
73
        else:
74
            depths = [child._max_child_depth() for child in self.children]
6✔
75
            self.params["max_child_depth"] = max(depths)
6✔
76
        return self.params["max_child_depth"]
6✔
77

78
    def _init_tip_ranks(self) -> None:
6✔
79
        tips = self.tips()
6✔
80
        num_tips = len(tips)
6✔
81
        for index, tip in enumerate(tips):
6✔
82
            tip._tip_rank = index
6✔
83
            tip._y = ((num_tips - 1) / 2 - index) * self.node_space
6✔
84

85
    def _init_length_depth_attr(self) -> None:
6✔
86
        """check it exists, if not, creates with default value of 1"""
87
        # we compute cumulative lengths first with sorting, as that dictates the ordering of children
88
        # then determin
89
        for edge in self.preorder():
6✔
90
            # making sure children have the correct setting for ._length
91
            edge._length = self._length
6✔
92
            edge._num_tips = self._num_tips
6✔
93
            edge.node_space = self.node_space
6✔
94
            if len(edge.children) > 0:
6✔
95
                # we order children by their length
96
                children = sorted(
6✔
97
                    [(c.params.get(self._length, 0) or 0, c) for c in edge.children],
98
                )
99
                edge.children = [c for _, c in children]
6✔
100

101
            if edge.is_root():
6✔
102
                edge.params["depth"] = 0
6✔
103
                edge.params[self._length] = 0
6✔
104
                continue
6✔
105

106
            parent = cast("Self", edge.parent)
6✔
107

108
            length = edge.params.get(self._length, None) or 1
6✔
109
            edge.params[self._length] = length
6✔
110
            edge.params["depth"] = parent.params.get("depth", 0) + 1
6✔
111

112
        self._max_child_depth()
6✔
113
        for edge in self.preorder():
6✔
114
            if edge.is_root():
6✔
115
                edge.params["cum_length"] = 0
6✔
116
                continue
6✔
117

118
            parent = cast("Self", edge.parent)
6✔
119

120
            parent_frac = parent.params.get("cum_length", 0)
6✔
121
            if edge.is_tip():
6✔
122
                frac = 1 - parent_frac
6✔
123
            else:
124
                frac = 1 / edge.params["max_child_depth"]
6✔
125
            edge.params["frac_pos"] = frac
6✔
126
            edge.params["cum_length"] = parent_frac + edge.params[self._length]
6✔
127

128
    @property
6✔
129
    def x(self) -> float:
6✔
130
        if self.is_root():
6✔
131
            self._x = 0
6✔
132
        elif self._x is None:
6✔
133
            val: float = self.params["cum_length"]
6✔
134
            self._x = val
6✔
135
        return self._x
6✔
136

137
    @property
6✔
138
    def y(self) -> float:
6✔
139
        if self._y is None:
×
140
            return 0
×
141
        return self._y
×
142

143
    @property
6✔
144
    def max_x(self) -> float:
6✔
145
        if not self._max_x:
6✔
146
            self._max_x = max(e.x for e in self.tips())
6✔
147
        return self._max_x
6✔
148

149
    @property
6✔
150
    def min_x(self) -> float:
6✔
151
        if not self._min_x:
6✔
152
            self._min_x = min([e.x for e in self.postorder()])
6✔
153
        return self._min_x
6✔
154

155
    @property
6✔
156
    def max_y(self) -> float:
6✔
157
        if not self._max_y:
6✔
158
            self._max_y = max(e.y for e in self.tips())
6✔
159
        return self._max_y
6✔
160

161
    @property
6✔
162
    def min_y(self) -> float:
6✔
163
        if not self._min_y:
6✔
164
            self._min_y = min(e.y for e in self.tips())
6✔
165
        return self._min_y
6✔
166

167
    @property
6✔
168
    def node_space(self) -> float:
6✔
169
        if self._node_space is None:
6✔
170
            msg = "Node space should not be None."
×
171
            raise ValueError(msg)
×
172

173
        return self._node_space
6✔
174

175
    @node_space.setter
6✔
176
    def node_space(self, value: float) -> None:
6✔
177
        if value < 0:
6✔
178
            msg = "node spacing must be > 0"
×
179
            raise ValueError(msg)
×
180
        self._node_space = value
6✔
181

182
    @property
6✔
183
    def tip_rank(self) -> int:
6✔
184
        if self._tip_rank is None:
6✔
185
            msg = "Tip rank should not be None"
×
186
            raise ValueError(msg)
×
187
        return self._tip_rank
6✔
188

189
    @property
6✔
190
    def theta(self) -> float:
6✔
191
        if self._theta is None:
6✔
192
            msg = "Theta should not be None."
×
193
            raise ValueError(msg)
×
194
        return self._theta
6✔
195

196
    @property
6✔
197
    def start(self) -> tuple[float, float]:
6✔
198
        """x, y coordinate for line connecting parent to this node"""
199
        # needs to ask parent, but has to do more than just get the parent
200
        # end, parent needs to know
201
        if self.parent is None:
6✔
202
            msg = "Node has no parent"
×
203
            raise ValueError(msg)
×
204
        return (0, self.y) if self.is_root() else (self.parent.x, self.y)
6✔
205

206
    @property
6✔
207
    def end(self) -> tuple[float, float]:
6✔
208
        """x, y coordinate for this node"""
209
        return self.x, self.y
6✔
210

211
    @property
6✔
212
    def depth(self) -> int:
6✔
213
        return self.params["depth"]
×
214

215
    def get_segment_to_parent(
6✔
216
        self,
217
    ) -> tuple[tuple[float, float] | tuple[None, None], ...]:
218
        """returns (self.start, self.end)"""
219
        if self.is_root():
6✔
220
            return ((None, None),)
6✔
221

222
        parent = cast("Self", self.parent)
6✔
223

224
        segment_start = parent.get_segment_to_child(self)
6✔
225
        result: Sequence[tuple[float, float] | tuple[None, None]]
226
        if isinstance(segment_start, list):
6✔
227
            result = [
6✔
228
                *segment_start,
229
                (None, None),
230
                self.start,
231
                self.end,
232
            ]
233
            return tuple(result)
6✔
234
        return segment_start, self.start, (None, None), self.start, self.end
6✔
235

236
    def get_segment_to_child(
6✔
237
        self, child: "TreeGeometryBase"
238
    ) -> tuple[float, float] | list[tuple[float, float]]:
239
        """returns coordinates connecting a child to self and descendants"""
240
        return self.end
×
241

242
    def value_and_coordinate(
6✔
243
        self, attr: str, padding: float = 0.1, max_attr_length: int | None = None
244
    ) -> UnionDict:
245
        """
246
        Parameters
247
        ----------
248
        attr
249
            attribute of self, e.g. 'name', or key in self.params
250
        padding
251
            distance from self coordinate
252
        max_attr_length
253
            maximum text length of the attribute
254
        Returns
255
        -------
256
        (value of attr, (x, y)
257
        """
258
        # TODO, possibly also return a rotation?
259
        msg = "implement in sub-class"
×
260
        raise NotImplementedError(msg)
×
261

262
    def support_text_coord(
6✔
263
        self,
264
        xshift: int | None,
265
        yshift: int | None,
266
        threshold: float = 100,
267
        max_attr_length: int | None = 4,
268
    ) -> UnionDict | None:
269
        """
270
        Parameters
271
        ----------
272
        xshift, yshift
273
            relative position (in pixels) of text
274
        threshold
275
            support values below this will be displayed
276
        max_attr_length
277
            maximum text length of the attribute
278

279
        Returns
280
        -------
281
        None if threshold not met, else params['support'] and coords
282
        """
283
        if xshift is None:
6✔
284
            xshift = -14
6✔
285

286
        if yshift is None:
6✔
287
            yshift = 7
6✔
288

289
        if self.is_tip():
6✔
290
            return None
6✔
291

292
        val = self.support
6✔
293
        if val is None or val > threshold or self.is_tip():
6✔
294
            return None
6✔
295

296
        if threshold > 1:
6✔
297
            # assuming we have support as a percentage
298
            text = f"{int(round(val, 0))}"
6✔
299
        else:
300
            text = f"{val:.2f}"
×
301

302
        x = self.x
6✔
303
        return UnionDict(
6✔
304
            x=x,
305
            y=self.y,
306
            xshift=xshift,
307
            yshift=yshift,
308
            textangle=self.theta,
309
            showarrow=False,
310
            text=text,
311
            xanchor="center",
312
        )
313

314

315
class SquareTreeGeometry(TreeGeometryBase):
6✔
316
    """represents Square dendrograms, contemporaneous or not"""
317

318
    def __init__(self, *args: Any, **kwargs: Any) -> None:
6✔
319
        super().__init__(*args, **kwargs)
6✔
320

321
    @property
6✔
322
    def y(self) -> float:
6✔
323
        if self._y is None:
6✔
324
            num_kids = len(self.children)
6✔
325
            even = num_kids % 2 == 0
6✔
326
            i = floor(num_kids / 2)
6✔
327
            if even:
6✔
328
                val = (self.children[i].y + self.children[i - 1].y) / 2
6✔
329
            else:
330
                val = self.children[i].y
6✔
331
            self._y = val
6✔
332
        return self._y
6✔
333

334
    def get_segment_to_child(self, child: TreeGeometryBase) -> tuple[float, float]:
6✔
335
        """returns coordinates connecting a child to self and descendants"""
336

337
        if not hasattr(self, "_ordered"):
6✔
338
            self._ordered = sorted(
6✔
339
                [(c.y, c.start) for c in self.children] + [(self.y, self.end)],
340
            )
341
        ordered = self._ordered
6✔
342
        dist = child.y - self.y
6✔
343
        if np.allclose(dist, 0):
6✔
344
            return self.end
6✔
345
        if dist < 0:
6✔
346
            result = ordered[ordered.index((child.y, child.start)) + 1][1]
6✔
347
        else:
348
            result = ordered[ordered.index((child.y, child.start)) - 1][1]
6✔
349

350
        return result
6✔
351

352
    @extend_docstring_from(TreeGeometryBase.value_and_coordinate)
6✔
353
    def value_and_coordinate(
6✔
354
        self,
355
        attr: str = "name",
356
        padding: float = 0.05,
357
        max_attr_length: int | None = None,
358
    ) -> UnionDict:
359
        # TODO, possibly also return a rotation?
360
        x = self.x + padding
6✔
361
        y = self.y
6✔
362
        value = self.params.get(attr, None)
6✔
363
        if value is None:
6✔
364
            value = getattr(self, attr, None)
6✔
365
        return UnionDict(
6✔
366
            x=x,
367
            y=y,
368
            textangle=self.theta,
369
            showarrow=False,
370
            text=value,
371
            xanchor="left",
372
        )
373

374

375
class _AngularGeometry(TreeGeometryBase):
6✔
376
    """directly connects child to parents"""
377

378
    @property
6✔
379
    def start(self) -> tuple[float, float]:
6✔
380
        """x, y coordinate for line connecting parent to this node"""
381
        if self.is_root():
6✔
382
            return (0, self.y)
×
383
        parent = cast("Self", self.parent)
6✔
384
        return parent.end
6✔
385

386

387
class AngularTreeGeometry(_AngularGeometry, SquareTreeGeometry):
6✔
388
    def __init__(self, *args: Any, **kwargs: Any) -> None:
6✔
389
        super().__init__(*args, **kwargs)
6✔
390

391

392
r_2_d = np.pi / 180
6✔
393

394

395
def polar_2_cartesian(
6✔
396
    theta: float | np.floating, radius: float | np.floating
397
) -> tuple[float, float]:
398
    radians = theta * r_2_d
6✔
399
    x = float(np.cos(radians) * radius)
6✔
400
    y = float(np.sin(radians) * radius)
6✔
401
    return x, y
6✔
402

403

404
class CircularTreeGeometry(TreeGeometryBase):
6✔
405
    def __init__(self, *args: Any, **kwargs: Any) -> None:
6✔
406
        super().__init__(*args, **kwargs)
6✔
407
        self._num_tips = 1
6✔
408
        self._theta: float | None = None
6✔
409
        self._node_space = None
6✔
410

411
    def propagate_properties(self) -> None:
6✔
412
        self._num_tips = len(self.tips())
6✔
413
        self._init_length_depth_attr()
6✔
414
        self._init_tip_ranks()
6✔
415

416
    @property
6✔
417
    def node_space(self) -> float:
6✔
418
        if self._node_space is None:
6✔
419
            self._node_space = 360 / self._num_tips
6✔
420
        return self._node_space
6✔
421

422
    @node_space.setter
6✔
423
    def node_space(self, value: float) -> None:
6✔
424
        if value < 0:
6✔
425
            msg = "node spacing must be > 0"
×
426
            raise ValueError(msg)
×
427
        if self._num_tips * value > 360:
6✔
428
            msg = f"{value} * {(self._num_tips + 1)} is > 360"
×
429
            raise ValueError(msg)
×
430
        self._node_space = value
6✔
431

432
    @property
6✔
433
    def theta(self) -> float:
6✔
434
        if self._theta is None:
6✔
435
            if self.is_root():
6✔
436
                self._theta = 0
6✔
437
            elif self.is_tip():
6✔
438
                self._theta = (self.tip_rank + 1) * self.node_space
6✔
439
            else:
440
                self._theta = sum(c.theta for c in self.children) / len(self.children)
6✔
441

442
        return self._theta
6✔
443

444
    @property
6✔
445
    def x(self) -> float:
6✔
446
        if self._x is None:
6✔
447
            _ = self.y  # triggers populating values
6✔
448
            self._x = cast("float", self._x)
6✔
449
        return self._x
6✔
450

451
    @property
6✔
452
    def y(self) -> float:
6✔
453
        radius: float = self.params["cum_length"]
6✔
454
        if self._y is None and self.is_root():
6✔
455
            self._x = self._y = 0
6✔
456
        elif self._x is None or self._y is None:
6✔
457
            x, y = polar_2_cartesian(self.theta, radius)
6✔
458
            self._x = x
6✔
459
            self._y = y
6✔
460

461
        return self._y
6✔
462

463
    @property
6✔
464
    def start(self) -> tuple[float, float]:
6✔
465
        """x, y coordinate for line connecting parent to this node"""
466
        # needs to ask parent, but has to do more than just get the parent
467
        # end, parent needs to know
468
        if self.is_root():
6✔
469
            return (0, 0)
×
470

471
        # radius comes from parent
472
        parent = cast("Self", self.parent)
6✔
473
        radius = parent.params["cum_length"]
6✔
474
        return polar_2_cartesian(self.theta, radius)
6✔
475

476
    @extend_docstring_from(TreeGeometryBase.value_and_coordinate)
6✔
477
    def value_and_coordinate(
6✔
478
        self,
479
        attr: str = "name",
480
        padding: float = 0.05,
481
        max_attr_length: int | None = None,
482
    ) -> UnionDict:
483
        value: str | None = self.params.get(attr, None)
6✔
484
        if value is None:
6✔
485
            value = cast("str", getattr(self, attr, None))
6✔
486

487
        max_attr_length = len(value) if max_attr_length is None else max_attr_length
6✔
488
        if 90 < self.theta <= 270:
6✔
489
            textangle = 180 - self.theta
6✔
490
            value = value.rjust(max_attr_length)
6✔
491
        else:
492
            textangle = 360 - self.theta
6✔
493
            value = value.ljust(max_attr_length)
6✔
494

495
        radius = np.sqrt(self.x**2 + self.y**2) + padding
6✔
496
        x, y = polar_2_cartesian(self.theta, radius)
6✔
497

498
        return UnionDict(
6✔
499
            x=x,
500
            y=y,
501
            textangle=textangle,
502
            showarrow=False,
503
            text=value,
504
            xanchor="center",
505
            yanchor="middle",
506
        )
507

508
    @extend_docstring_from(TreeGeometryBase.support_text_coord)
6✔
509
    def support_text_coord(
6✔
510
        self,
511
        xshift: float | None,
512
        yshift: float | None,
513
        threshold: float = 100,
514
        max_attr_length: int | None = 4,
515
    ) -> UnionDict | None:
516
        if xshift is None:
×
517
            xshift = -18
×
518

519
        if yshift is None:
×
520
            yshift = 3
×
521

522
        if self.is_tip():
×
523
            return None
×
524

NEW
525
        val = self.support
×
526
        if val is None or val > threshold or self.is_tip():
×
527
            return None
×
528

529
        if threshold > 1:
×
530
            # assuming we have support as a percentage
531
            text = f"{int(round(val, 0))}"
×
532
        else:
533
            text = f"{val:.2f}"
×
534

535
        if 90 < self.theta <= 270:
×
536
            textangle = 180 - self.theta
×
537
        else:
538
            textangle = 360 - self.theta
×
539
            xshift = -xshift
×
540

541
        c = np.cos(textangle * (np.pi / 180))
×
542
        s = np.sin(textangle * (np.pi / 180))
×
543
        m = np.array([[c, s], [-s, c]])
×
544
        d = np.dot(m, [xshift, yshift])
×
545

546
        new_xshift = float(d.T[0])
×
547
        new_yshift = float(d.T[1])
×
548

549
        x = self.x
×
550
        return UnionDict(
×
551
            x=x,
552
            y=self.y,
553
            xshift=new_xshift,
554
            yshift=new_yshift,
555
            textangle=textangle,
556
            showarrow=False,
557
            text=text,
558
            xanchor="center",
559
        )
560

561
    def get_segment_to_child(
6✔
562
        self, child: TreeGeometryBase
563
    ) -> tuple[float, float] | list[tuple[float, float]]:
564
        """returns coordinates connecting a child to self and descendants"""
565

566
        if not hasattr(self, "_ordered"):
6✔
567
            self._ordered = sorted(
6✔
568
                [(c.theta, c.start) for c in self.children] + [(self.theta, self.end)],
569
            )
570
        ordered = self._ordered
6✔
571
        dist = child.theta - self.theta
6✔
572
        if np.allclose(dist, 0):
6✔
573
            return self.end
×
574
        if dist < 0:
6✔
575
            neighbours = [
6✔
576
                ordered[ordered.index((child.theta, child.start)) + 1],
577
                (child.theta, child.start),
578
            ]
579
        else:
580
            neighbours = [
6✔
581
                ordered[ordered.index((child.theta, child.start)) - 1],
582
                (child.theta, child.start),
583
            ]
584

585
        neighbours = sorted(neighbours)
6✔
586
        result = [
6✔
587
            polar_2_cartesian(theta, self.params["cum_length"])
588
            for theta in np.arange(neighbours[0][0], neighbours[1][0], 5)
589
        ]
590
        result.append(neighbours[1][1])
6✔
591

592
        return result
6✔
593

594

595
class RadialTreeGeometry(_AngularGeometry, CircularTreeGeometry):
6✔
596
    def __init__(self, *args: Any, **kwargs: Any) -> None:
6✔
597
        super().__init__(*args, **kwargs)
6✔
598

599
    def get_segment_to_child(self, child: TreeGeometryBase) -> tuple[float, float]:
6✔
600
        """returns coordinates connecting a child to self and descendants"""
601
        return self.end
6✔
602

603

604
class Dendrogram(Drawable):
6✔
605
    def __init__(
6✔
606
        self,
607
        tree: PhyloNode,
608
        style: Literal["square", "circular", "angular", "radial"] = "square",
609
        label_pad: float | None = None,
610
        contemporaneous: bool = False,
611
        show_support: bool = True,
612
        threshold: float = 1.0,
613
        support_is_percent: bool = True,
614
        *args: Any,
615
        **kwargs: Any,
616
    ) -> None:
617
        length_attr: str | None = kwargs.pop("length_attr", None)
6✔
618
        super().__init__(
6✔
619
            *args,
620
            visible_axes=False,  # type: ignore[misc]
621
            showlegend=False,  # type: ignore[misc]
622
            **kwargs,
623
        )
624
        style_to_geometry: dict[str, type[TreeGeometryBase]] = {
6✔
625
            "square": SquareTreeGeometry,
626
            "circular": CircularTreeGeometry,
627
            "angular": AngularTreeGeometry,
628
            "radial": RadialTreeGeometry,
629
        }
630
        klass = style_to_geometry[style]
6✔
631
        if length_attr is None and not contemporaneous:
6✔
632
            contemporaneous = tree.children[0].length is None
6✔
633

634
        length_attr = "frac_pos" if contemporaneous else length_attr or "length"
6✔
635
        kwargs = UnionDict(length_attr=length_attr) if contemporaneous else {}
6✔
636
        self.tree = klass(tree, **kwargs)
6✔
637
        self.tree.propagate_properties()
6✔
638
        self._label_pad = label_pad
6✔
639
        self._tip_font = UnionDict(size=12, family="Inconsolata, monospace")
6✔
640
        self._line_width = 1.25
6✔
641
        self._marker_size = 3.0
6✔
642
        self._line_color = "black"
6✔
643
        self._scale_bar: str | bool | None = "bottom left"
6✔
644
        self._edge_sets: dict[frozenset[str] | None, UnionDict] = {}
6✔
645
        self._edge_mapping: dict[str, frozenset[str]] = {}
6✔
646
        self._contemporaneous = contemporaneous
6✔
647
        self._tips_as_text = True
6✔
648
        self._length_attr = self.tree._length  # type: ignore[reportPrivateUsage]
6✔
649
        self._tip_names = tuple(e.name for e in self.tree.tips())
6✔
650
        self._max_label_length = max(map(len, self._tip_names))
6✔
651
        if all(child.support is None for child in self.tree.children):
6✔
652
            show_support = False
6✔
653
        self._show_support = show_support
6✔
654

655
        if support_is_percent and threshold <= 1.0:
6✔
656
            threshold *= 100
6✔
657

658
        self._threshold = threshold
6✔
659
        self._support_xshift: int | None = None
6✔
660
        self._support_yshift: int | None = None
6✔
661
        self._default_layout.autosize = True
6✔
662
        self.layout = UnionDict(self._default_layout)
6✔
663

664
    @property
6✔
665
    def label_pad(self) -> float:
6✔
666
        default = 0.15 if isinstance(self.tree, CircularTreeGeometry) else 0.025
6✔
667
        if self._label_pad is None:
6✔
668
            if not self.contemporaneous:
6✔
669
                max_x = max(self.tree.max_x, abs(self.tree.min_x))
6✔
670
                self._label_pad = max_x * default
6✔
671
            else:
672
                self._label_pad = default
×
673
        return self._label_pad
6✔
674

675
    @label_pad.setter
6✔
676
    def label_pad(self, value: float | None) -> None:
6✔
677
        self._label_pad = value
×
678
        self._traces = []
×
679

680
    @property
6✔
681
    def support_xshift(self) -> int | None:
6✔
682
        """relative x position (in pixels) of support text. Can be negative or positive."""
683
        return self._support_xshift
6✔
684

685
    @support_xshift.setter
6✔
686
    def support_xshift(self, value: int) -> None:
6✔
687
        if value == self._support_xshift:
×
688
            return
×
689
        self._support_xshift = value
×
690
        self._traces = []
×
691

692
    @property
6✔
693
    def support_yshift(self) -> int | None:
6✔
694
        """relative y position (in pixels) of support text. Can be negative or positive."""
695
        return self._support_yshift
6✔
696

697
    @support_yshift.setter
6✔
698
    def support_yshift(self, value: int) -> None:
6✔
699
        if value == self._support_yshift:
×
700
            return
×
701
        self._support_yshift = value
×
702
        self._traces = []
×
703

704
    @property
6✔
705
    def contemporaneous(self) -> bool:
6✔
706
        return self._contemporaneous
6✔
707

708
    @contemporaneous.setter
6✔
709
    def contemporaneous(self, value: bool) -> None:
6✔
710
        if not isinstance(value, bool):
×
711
            raise TypeError
×
712
        if self._contemporaneous != value:
×
713
            klass = self.tree.__class__
×
714
            length_attr = "frac_pos" if value else self._length_attr
×
715
            self.tree = klass(self.tree, length_attr=length_attr)
×
716
            self.tree.propagate_properties()
×
717
            self._traces = []
×
718
            self.layout["xaxis"] |= {"range": None, "autorange": True}
×
719
            self.layout["yaxis"] |= {"range": None, "autorange": True}
×
720
            if value:  # scale bar not needed
×
721
                self._scale_bar = False
×
722

723
        self._contemporaneous = value
×
724

725
    def _update_tip_font(self, val: dict[Any, Any] | int) -> None:
6✔
726
        """update tip font settings"""
727
        if isinstance(val, dict):
6✔
728
            self._tip_font |= val
6✔
729
            return
6✔
730
        if isinstance(val, int):
×
731
            self._tip_font.size = val
×
732
            return
×
733

734
        msg = f"{type(val)} not a supported type for tip_font"
×
735
        raise TypeError(msg)
×
736

737
    @property
6✔
738
    def tip_font(self) -> dict[Any, Any]:
6✔
739
        return self._tip_font
6✔
740

741
    @tip_font.setter
6✔
742
    def tip_font(self, val: dict[Any, Any] | int) -> None:
6✔
743
        """update tip font settings"""
744
        self._update_tip_font(val)
6✔
745

746
    def _scale_label_pad(self) -> float:
6✔
747
        """returns the label pad scaled by maximum dist to tip"""
748
        return self.label_pad
×
749

750
    def _get_tip_name_annotations(self) -> list[UnionDict]:
6✔
751
        annotations: list[UnionDict] = []
6✔
752
        for tip in self.tree.tips():
6✔
753
            anote = tip.value_and_coordinate(
6✔
754
                "name",
755
                padding=self.label_pad,
756
                max_attr_length=self._max_label_length,
757
            )
758
            anote |= UnionDict(xref="x", yref="y", font=self.tip_font)
6✔
759
            annotations.append(anote)
6✔
760
        return annotations
6✔
761

762
    def _get_scale_bar(self) -> tuple[dict[str, Any], UnionDict] | tuple[None, None]:
6✔
763
        if not self.scale_bar or self.contemporaneous:
6✔
764
            return None, None
6✔
765
        self.scale_bar = cast("str", self.scale_bar)
6✔
766

767
        # place scale bar above / below dendrogram area
768
        y_shift = (self.tree.max_y - self.tree.min_y) / 11
6✔
769
        x = self.tree.min_x if "left" in self.scale_bar else self.tree.max_x
6✔
770
        y = (
6✔
771
            self.tree.min_y - y_shift
772
            if "bottom" in self.scale_bar
773
            else self.tree.max_y + y_shift
774
        )
775
        scale = 0.1 * self.tree.max_x
6✔
776
        text = f"{scale:.1e}" if scale < 1e-2 else f"{scale:.2f}"
6✔
777
        shape: dict[str, Any] = {
6✔
778
            "type": "line",
779
            "x0": x,
780
            "y0": y,
781
            "x1": x + scale,
782
            "y1": y,
783
            "line": {"color": self._line_color, "width": self._line_width},
784
            "name": "scale_bar",
785
        }
786
        annotation = UnionDict(
6✔
787
            x=x + (0.5 * scale),
788
            y=y,
789
            xref="x",
790
            yref="y",
791
            yshift=10,
792
            text=text,
793
            showarrow=False,
794
            ax=0,
795
            ay=0,
796
        )
797
        return shape, annotation
6✔
798

799
    def _build_fig(self, **kwargs: Any) -> None:
6✔
800
        grouped: dict[frozenset[str] | None, defaultdict[str, list[Any]]] = {}
6✔
801

802
        tree = self.tree
6✔
803
        text = UnionDict(
6✔
804
            {
805
                "type": "scatter",
806
                "text": [],
807
                "x": [],
808
                "y": [],
809
                "hoverinfo": "text",
810
                "mode": "markers",
811
                "marker": {
812
                    "symbol": "circle",
813
                    "color": "black",
814
                    "size": self._marker_size,
815
                },
816
                "showlegend": False,
817
            },
818
        )
819
        support_text: list[UnionDict] = []
6✔
820
        get_edge_group = self._edge_mapping.get
6✔
821
        for edge in tree.preorder():
6✔
822
            key = get_edge_group(edge.name, None)
6✔
823
            if key not in grouped:
6✔
824
                grouped[key] = defaultdict(list)
6✔
825
            group = grouped[key]
6✔
826
            coords = edge.get_segment_to_parent()
6✔
827
            xs, ys = list(zip(*coords, strict=False))
6✔
828
            group["x"].extend((*xs, None))
6✔
829
            group["y"].extend((*ys, None))
6✔
830

831
            edge_label = edge.value_and_coordinate("name", padding=0)
6✔
832
            text["x"].append(edge_label.x)  # type: ignore[reportUnknownMemberType]
6✔
833
            text["y"].append(edge_label.y)  # type: ignore[reportUnknownMemberType]
6✔
834
            text["text"].append(edge_label.text)  # type: ignore[reportUnknownMemberType]
6✔
835
            if self.show_support:
6✔
836
                support = edge.support_text_coord(
6✔
837
                    self.support_xshift,
838
                    self.support_yshift,
839
                    threshold=self.support_threshold,
840
                )
841
                if support is not None:
6✔
842
                    support |= UnionDict(xref="x", yref="y", font=self.tip_font)
6✔
843
                    support_text.append(support)
6✔
844

845
        traces: list[UnionDict] = []
6✔
846
        for key, group in grouped.items():
6✔
847
            style = self._edge_sets.get(
6✔
848
                key,
849
                UnionDict(
850
                    line=UnionDict(
851
                        width=self._line_width,
852
                        color=self._line_color,
853
                        shape="spline",
854
                        smoothing=1.3,
855
                    ),
856
                ),
857
            )
858
            trace = UnionDict(type="scatter", x=group["x"], y=group["y"], mode="lines")
6✔
859
            trace |= style
6✔
860
            if "legendgroup" not in style:
6✔
861
                trace["showlegend"] = False
6✔
862
            else:
863
                trace["name"] = style["legendgroup"]
×
864
            traces.append(trace)
6✔
865

866
        scale_shape, scale_text = self._get_scale_bar()
6✔
867
        traces.extend([text])
6✔
868
        self.traces.extend(traces)
6✔
869
        if self.tips_as_text:
6✔
870
            self.layout.annotations = tuple(self._get_tip_name_annotations())
6✔
871

872
        if self.show_support and support_text:
6✔
873
            self.layout.annotations = self.layout.annotations + tuple(support_text)  # type: ignore[reportUnknownMemberType]
6✔
874

875
        if scale_shape:
6✔
876
            self.layout.shapes = [*self.layout.get("shape", []), scale_shape]  # type: ignore[reportUnknownMemberType]
6✔
877
            self.layout.annotations += (scale_text,)  # type: ignore[reportUnknownMemberType]
6✔
878
        else:
879
            self.layout.pop("shapes", None)  # type: ignore[reportUnknownMemberType]
6✔
880

881
        # TODO: look into TypedDict as all the type: ignores are getting a bit silly
882
        if isinstance(self.tree, CircularTreeGeometry):
6✔
883
            # must draw this square
884
            if self.layout.width and self.layout.height:  # type: ignore[reportUnknownMemberType]
6✔
885
                dim = max(self.layout.width, self.layout.height)  # type: ignore[reportUnknownMemberType]
6✔
886
            elif self.layout.width:  # type: ignore[reportUnknownMemberType]
6✔
887
                dim = self.layout.width  # type: ignore[reportUnknownMemberType]
×
888
            elif self.layout.height:  # type: ignore[reportUnknownMemberType]
6✔
889
                dim = self.layout.height  # type: ignore[reportUnknownMemberType]
×
890
            else:
891
                dim = 800
6✔
892
            self.layout.width = self.layout.height = dim
6✔
893

894
            # Span of tree along x-axis and Span of tree along y-axis
895
            x_diff = self.tree.max_x - self.tree.min_x
6✔
896
            y_diff = self.tree.max_y - self.tree.min_y
6✔
897

898
            # Maximum span
899
            max_span = max(x_diff, y_diff)
6✔
900

901
            # Use maximum span along both axes and pad the smaller one accordingly
902
            axes_range = {
6✔
903
                "xaxis": {
904
                    "range": [
905
                        self.tree.min_x - (1.4 * max_span - x_diff) / 2,
906
                        self.tree.max_x + (1.4 * max_span - x_diff) / 2,
907
                    ],
908
                },
909
                "yaxis": {
910
                    "range": [
911
                        self.tree.min_y - (1.4 * max_span - y_diff) / 2,
912
                        self.tree.max_y + (1.4 * max_span - y_diff) / 2,
913
                    ],
914
                },
915
            }
916
            self.layout |= axes_range
6✔
917

918
    def style_edges(
6✔
919
        self,
920
        edges: str | Iterable[str],
921
        line: dict[Any, Any],
922
        legendgroup: str | None = None,
923
        tip2: str | None = None,
924
        **kwargs: Any,
925
    ) -> None:
926
        """adjust display layout for the edges
927

928
        Parameters
929
        ----------
930
        edges : str or series
931
            names of edges
932
        line : dict
933
            with plotly line style to applied to these edges
934
        legendgroup : str or None
935
            if str, a legend will be presented
936
        tip2 : str
937
            if provided, and edges is a str, passes edges (as tip1) and kwargs to get_edge_names
938
        kwargs
939
            keyword arguments passed onto get_edge_names
940
        """
941
        if tip2:
6✔
942
            if not isinstance(edges, str):
×
943
                msg = "cannot use a series of edges and tip2"
×
944
                raise TypeError(msg)
×
945
            edges = self.get_edge_names(edges, tip2, **kwargs)
×
946

947
        if isinstance(edges, str):
6✔
948
            edges = [edges]
6✔
949
        edges = frozenset(edges)
6✔
950
        if not edges.issubset({edge.name for edge in self.tree.preorder()}):
6✔
951
            msg = "edge not present in tree"
6✔
952
            raise ValueError(msg)
6✔
953
        style = UnionDict(width=self._line_width, color=self._line_color)
6✔
954
        style.update(line)  # type: ignore[reportUnknownMemberType]
6✔
955
        self._edge_sets[edges] = UnionDict(legendgroup=legendgroup, line=style)
6✔
956
        mapping = dict.fromkeys(edges, edges)
6✔
957
        self._edge_mapping.update(mapping)
6✔
958
        if legendgroup:
6✔
959
            self.layout["showlegend"] = True
×
960

961
        # need to trigger recreation of figure
962
        self._traces = []
6✔
963

964
    def reorient(self, name: str, tip2: str | None = None, **kwargs: Any) -> None:
6✔
965
        """change orientation of tree
966
        Parameters
967
        ----------
968
        name : str
969
            name of an edge in the tree. If name is a tip, its parent becomes
970
            the new root, otherwise the edge becomes the root.
971
        tip2 : str
972
            if provided, passes name (as tip1) and all other args to get_edge_names,
973
            but sets clade=False and stem=True
974
        kwargs
975
            keyword arguments passed onto get_edge_names
976
        """
977
        if tip2:
×
978
            kwargs.update({"stem": True, "clade": False})
×
979
            edges = self.get_edge_names(name, tip2, **kwargs)
×
980
            name = edges[0]
×
981

982
        if name in self._tip_names:
×
983
            self.tree = self.tree.rooted_with_tip(name)
×
984
        else:
985
            self.tree = self.tree.rooted_at(name)
×
986

987
        self.tree.propagate_properties()
×
988
        self._traces = []
×
989

990
    def get_edge_names(
6✔
991
        self,
992
        tip1: str,
993
        tip2: str,
994
        outgroup: str | None = None,
995
        stem: bool = False,
996
        clade: bool = True,
997
    ) -> list[str]:
998
        """
999

1000
        Parameters
1001
        ----------
1002
        tip1 : str
1003
            name of tip 1
1004
        tip2 : str
1005
            name of tip 1
1006
        outgroup : str
1007
            name of tip outside clade of interest
1008
        stem : bool
1009
            include name of stem to clade defined by tip1, tip2, outgroup
1010
        clade : bool
1011
            include names of edges within clade defined by tip1, tip2, outgroup
1012

1013
        Returns
1014
        -------
1015
        list of edge names
1016
        """
1017
        return self.tree.get_edge_names(
6✔
1018
            tip1,
1019
            tip2,
1020
            stem=stem,
1021
            clade=clade,
1022
            outgroup_name=outgroup,
1023
        )
1024

1025
    @property
6✔
1026
    def scale_bar(self) -> str | bool | None:
6✔
1027
        """where to place a scale bar"""
1028
        return self._scale_bar
6✔
1029

1030
    @scale_bar.setter
6✔
1031
    def scale_bar(self, value: str | bool | None) -> None:
6✔
1032
        if value is True:
6✔
1033
            value = "bottom left"
×
1034

1035
        valid: set[str | bool | None] = {
6✔
1036
            "bottom left",
1037
            "bottom right",
1038
            "top left",
1039
            "top right",
1040
            False,
1041
            None,
1042
        }
1043

1044
        if value not in valid:
6✔
1045
            msg = "Invalid scale bar."
×
1046
            raise ValueError(msg)
×
1047
        if value != self._scale_bar:
6✔
1048
            self._traces = []
6✔
1049
        self._scale_bar = value
6✔
1050

1051
    @property
6✔
1052
    def tips_as_text(self) -> bool:
6✔
1053
        """displays tips as text"""
1054
        return self._tips_as_text
6✔
1055

1056
    @tips_as_text.setter
6✔
1057
    def tips_as_text(self, value: bool) -> None:
6✔
1058
        if value == self._tips_as_text:
×
1059
            return
×
1060

1061
        self._tips_as_text = value
×
1062
        self._traces = []
×
1063
        self.layout.annotations = ()
×
1064

1065
    @property
6✔
1066
    def line_width(self) -> float:
6✔
1067
        """width of dendrogram lines"""
1068
        return self._line_width
×
1069

1070
    @line_width.setter
6✔
1071
    def line_width(self, width: float) -> None:
6✔
1072
        self._line_width = width
×
1073
        if self.traces:
×
1074
            setting = {"width": width}
×
1075
            for trace in self.traces:
×
1076
                with contextlib.suppress(KeyError):
×
1077
                    trace["line"] |= setting
×
1078

1079
    @property
6✔
1080
    def marker(self) -> float:
6✔
1081
        return self._marker_size
×
1082

1083
    @marker.setter
6✔
1084
    def marker(self, size: float) -> None:
6✔
1085
        self._marker_size = size
×
1086
        if self.traces:
×
1087
            setting = {"size": size}
×
1088
            for trace in self.traces:
×
1089
                if trace.get("mode", None) == "markers":  # type: ignore[reportUnknownMemberType]
×
1090
                    trace["marker"] |= setting
×
1091

1092
    @property
6✔
1093
    def show_support(self) -> bool:
6✔
1094
        """whether tree edge support entries are displayed"""
1095
        return self._show_support
6✔
1096

1097
    @show_support.setter
6✔
1098
    def show_support(self, value: bool) -> None:
6✔
1099
        """whether tree edge support entries are displayed"""
1100
        if value == self._show_support:
×
1101
            return
×
1102

1103
        self._show_support = value
×
1104
        self._traces = []
×
1105
        self.layout.annotations = ()
×
1106

1107
    @property
6✔
1108
    def support_threshold(self) -> float:
6✔
1109
        """cutoff for dislaying support"""
1110
        return self._threshold
6✔
1111

1112
    @support_threshold.setter
6✔
1113
    def support_threshold(self, value: float) -> None:
6✔
1114
        self._threshold = value
×
1115
        self._traces = []
×
1116
        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