Skip to content

Visualization

plot_tree(tree: Tree, *, backend: Literal['matplotlib', 'plotly'] = 'matplotlib', path: str | os.PathLike | None = None, show: bool = False, edge_length_mode: EdgeLengthMode | Literal['attr', 'uniform', 'even', 'rank'] = EdgeLengthMode.ATTR, edge_length_attr: str = 'dist', layout_mode: LayoutMode | Literal['horizontal', 'vertical', 'circular'] = LayoutMode.HORIZONTAL, node_rank_mode: NodeRankMode | Literal['mean', 'first', 'last', 'node'] = NodeRankMode.MEAN, tree_style: TreeStyle | None = None, **renderer_kwargs: Any) -> tuple[Figure, Axes] | PlotlyFigure

Lay out and render tree in a single call.

Convenience wrapper around :class:~tralda.visualization.layout.TreeLayout and :class:~tralda.visualization.matplotlib_renderer.MatplotlibRenderer / :class:~tralda.visualization.plotly_renderer.PlotlyRenderer. Construct those objects directly for more control (e.g. drawing onto an existing matplotlib Axes, or reusing one layout with several styles).

Parameters:

Name Type Description Default
tree Tree

The tree to render.

required
backend Literal['matplotlib', 'plotly']

"matplotlib" (default) or "plotly".

'matplotlib'
path str | PathLike | None

If given, save the resulting figure to this path. For the matplotlib backend the format is inferred from the file extension by :meth:~matplotlib.figure.Figure.savefig (e.g. .png, .pdf, .svg). For the plotly backend, a .html extension writes a self-contained interactive file via :meth:~plotly.graph_objects.Figure.write_html; any other extension writes a static image via :meth:~plotly.graph_objects.Figure.write_image, which requires the kaleido package (included in the plotly extra: pip install tralda[plotly]).

None
show bool

If True, display the figure (fig.show()) after rendering.

False
edge_length_mode EdgeLengthMode | Literal['attr', 'uniform', 'even', 'rank']

How edge lengths are determined. See :class:~tralda.visualization.layout.EdgeLengthMode.

ATTR
edge_length_attr str

Node attribute read as edge length in "attr" mode.

'dist'
layout_mode LayoutMode | Literal['horizontal', 'vertical', 'circular']

Tree orientation. See :class:~tralda.visualization.layout.LayoutMode.

HORIZONTAL
node_rank_mode NodeRankMode | Literal['mean', 'first', 'last', 'node']

How the rank of internal nodes is derived from their children. See :class:~tralda.visualization.layout.NodeRankMode.

MEAN
tree_style TreeStyle | None

Styling configuration. When None the default style is used.

None
**renderer_kwargs Any

Additional keyword arguments forwarded to the renderer constructor (e.g. figsize / ax for matplotlib, width / height / hover_attrs for plotly).

{}

Returns:

Type Description
tuple[Figure, Axes] | Figure

(fig, ax) for the matplotlib backend, or a Plotly Figure for the plotly backend.

Raises:

Type Description
ValueError

If backend is not "matplotlib" or "plotly".

ImportError

If the optional dependency required by backend is not installed.

Source code in tralda/visualization/_convenience.py
def plot_tree(
    tree: Tree,
    *,
    backend: Literal["matplotlib", "plotly"] = "matplotlib",
    path: str | os.PathLike | None = None,
    show: bool = False,
    edge_length_mode: EdgeLengthMode
    | Literal["attr", "uniform", "even", "rank"] = EdgeLengthMode.ATTR,
    edge_length_attr: str = "dist",
    layout_mode: LayoutMode | Literal["horizontal", "vertical", "circular"] = LayoutMode.HORIZONTAL,
    node_rank_mode: NodeRankMode | Literal["mean", "first", "last", "node"] = NodeRankMode.MEAN,
    tree_style: TreeStyle | None = None,
    **renderer_kwargs: Any,
) -> tuple[Figure, Axes] | PlotlyFigure:
    """Lay out and render *tree* in a single call.

    Convenience wrapper around :class:`~tralda.visualization.layout.TreeLayout` and
    :class:`~tralda.visualization.matplotlib_renderer.MatplotlibRenderer` /
    :class:`~tralda.visualization.plotly_renderer.PlotlyRenderer`. Construct those objects directly
    for more control (e.g. drawing onto an existing matplotlib ``Axes``, or reusing one layout with
    several styles).

    Args:
        tree: The tree to render.
        backend: ``"matplotlib"`` (default) or ``"plotly"``.
        path: If given, save the resulting figure to this path. For the matplotlib backend the
            format is inferred from the file extension by
            :meth:`~matplotlib.figure.Figure.savefig` (e.g. ``.png``, ``.pdf``, ``.svg``). For the
            plotly backend, a ``.html`` extension writes a self-contained interactive file via
            :meth:`~plotly.graph_objects.Figure.write_html`; any other extension writes a static
            image via :meth:`~plotly.graph_objects.Figure.write_image`, which requires the
            ``kaleido`` package (included in the ``plotly`` extra: ``pip install tralda[plotly]``).
        show: If ``True``, display the figure (``fig.show()``) after rendering.
        edge_length_mode: How edge lengths are determined. See
            :class:`~tralda.visualization.layout.EdgeLengthMode`.
        edge_length_attr: Node attribute read as edge length in ``"attr"`` mode.
        layout_mode: Tree orientation. See :class:`~tralda.visualization.layout.LayoutMode`.
        node_rank_mode: How the rank of internal nodes is derived from their children. See
            :class:`~tralda.visualization.layout.NodeRankMode`.
        tree_style: Styling configuration. When ``None`` the default style is used.
        **renderer_kwargs: Additional keyword arguments forwarded to the renderer constructor
            (e.g. ``figsize`` / ``ax`` for matplotlib, ``width`` / ``height`` / ``hover_attrs`` for
            plotly).

    Returns:
        ``(fig, ax)`` for the matplotlib backend, or a Plotly ``Figure`` for the plotly backend.

    Raises:
        ValueError: If *backend* is not ``"matplotlib"`` or ``"plotly"``.
        ImportError: If the optional dependency required by *backend* is not installed.
    """
    layout = TreeLayout(
        tree,
        edge_length_mode=edge_length_mode,
        edge_length_attr=edge_length_attr,
        layout_mode=layout_mode,
        node_rank_mode=node_rank_mode,
    )

    if backend == "matplotlib":
        from tralda.visualization.matplotlib_renderer import MatplotlibRenderer

        fig, ax = MatplotlibRenderer(layout, tree_style=tree_style, **renderer_kwargs).render()
        if path is not None:
            fig.savefig(path, bbox_inches="tight")
        if show:
            fig.show()
        return fig, ax

    elif backend == "plotly":
        from tralda.visualization.plotly_renderer import PlotlyRenderer

        fig = PlotlyRenderer(layout, tree_style=tree_style, **renderer_kwargs).render()
        if path is not None:
            if os.path.splitext(str(path))[1].lower() == ".html":
                fig.write_html(path)
            else:
                try:
                    fig.write_image(path)
                except (ImportError, ValueError) as exc:
                    if "kaleido" not in str(exc).lower():
                        raise
                    raise ImportError(
                        "Static image export for the plotly backend requires the 'kaleido' "
                        "package. Install it with: pip install tralda[plotly]"
                    ) from exc
        if show:
            fig.show()
        return fig

    else:
        raise ValueError(f"unknown backend {backend!r}; expected 'matplotlib' or 'plotly'")

Layout

TreeLayout

Computes and stores the geometric layout of a tree for visualization.

Depths and leaf ranks are computed once during construction. The final screen-space positions are derived from these canonical coordinates according to the chosen :class:LayoutMode and stored in :attr:positions. Calling :meth:compute_positions again with a different :class:LayoutMode re-derives the positions without repeating the depth computation.

Attributes:

Name Type Description
tree Tree

The tree being laid out.

edge_length_mode EdgeLengthMode

The edge-length mode used during construction.

edge_length_attr str

The node attribute read as edge length in ATTR mode.

layout_mode LayoutMode

The current layout orientation.

node_rank_mode NodeRankMode

How the rank of internal nodes is derived.

depths dict[TreeNode, float]

Canonical depth of every node from the root.

leaf_ranks dict[TreeNode, float]

Perpendicular rank of every node. Leaves receive integer ranks 0, 1, …; internal nodes receive a rank derived from their children according to :attr:node_rank_mode.

max_depth float

Maximum depth value (depth at which the outermost leaves sit).

leaf_count int

Total number of leaves.

rank_span int

Number of distinct perpendicular rank slots occupied by nodes. Equals leaf_count for all :class:NodeRankMode values except NODE, where every node (leaf and internal) occupies its own slot, so rank_span equals the total node count.

positions dict[TreeNode, tuple[float, float]]

Final (x, y) screen-space coordinates.

label_angle dict[TreeNode, float]

Suggested text rotation in degrees for each node. 0 = horizontal text; positive values rotate counter-clockwise.

label_ha dict[TreeNode, str]

Suggested horizontalalignment for each node label.

label_va dict[TreeNode, str]

Suggested verticalalignment for each node label.

Source code in tralda/visualization/layout.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
class TreeLayout:
    """Computes and stores the geometric layout of a tree for visualization.

    Depths and leaf ranks are computed once during construction. The final screen-space positions
    are derived from these canonical coordinates according to the chosen :class:`LayoutMode` and
    stored in :attr:`positions`. Calling :meth:`compute_positions` again with a different
    :class:`LayoutMode` re-derives the positions without repeating the depth computation.

    Attributes:
        tree (Tree): The tree being laid out.
        edge_length_mode (EdgeLengthMode): The edge-length mode used during construction.
        edge_length_attr (str): The node attribute read as edge length in ``ATTR`` mode.
        layout_mode (LayoutMode): The current layout orientation.
        node_rank_mode (NodeRankMode): How the rank of internal nodes is derived.
        depths (dict[TreeNode, float]): Canonical depth of every node from the root.
        leaf_ranks (dict[TreeNode, float]): Perpendicular rank of every node. Leaves receive
            integer ranks ``0, 1, …``; internal nodes receive a rank derived from their children
            according to :attr:`node_rank_mode`.
        max_depth (float): Maximum depth value (depth at which the outermost leaves sit).
        leaf_count (int): Total number of leaves.
        rank_span (int): Number of distinct perpendicular rank slots occupied by nodes.  Equals
            *leaf_count* for all :class:`NodeRankMode` values except ``NODE``, where every node
            (leaf and internal) occupies its own slot, so *rank_span* equals the total node count.
        positions (dict[TreeNode, tuple[float, float]]): Final ``(x, y)`` screen-space coordinates.
        label_angle (dict[TreeNode, float]): Suggested text rotation in degrees for each node.
            0 = horizontal text; positive values rotate counter-clockwise.
        label_ha (dict[TreeNode, str]): Suggested ``horizontalalignment`` for each node label.
        label_va (dict[TreeNode, str]): Suggested ``verticalalignment`` for each node label.
    """

    def __init__(
        self,
        tree: Tree,
        edge_length_mode: EdgeLengthMode
        | Literal["attr", "uniform", "even", "rank"] = EdgeLengthMode.ATTR,
        edge_length_attr: str = "dist",
        layout_mode: LayoutMode
        | Literal["horizontal", "vertical", "circular"] = LayoutMode.HORIZONTAL,
        node_rank_mode: NodeRankMode | Literal["mean", "first", "last", "node"] = NodeRankMode.MEAN,
    ) -> None:
        """Construct a layout for the given tree.

        Args:
            tree: The tree to lay out.
            edge_length_mode: How edge lengths along the depth axis are determined. Accepts an
                :class:`EdgeLengthMode` member or its string value (``"attr"``, ``"uniform"``,
                ``"even"``, ``"rank"``).
            edge_length_attr: Name of the :class:`~tralda.datastructures.TreeNode` attribute used
                as the edge length when *edge_length_mode* is ``ATTR``. Default is ``"dist"``.
            layout_mode: Orientation of the resulting drawing. Accepts a :class:`LayoutMode` member
                or its string value (``"horizontal"``, ``"vertical"``, ``"circular"``).
            node_rank_mode: How the perpendicular rank of internal nodes is derived from their
                children. Accepts a :class:`NodeRankMode` member or its string value
                (``"mean"``, ``"first"``, ``"last"``, ``"node"``).
        """
        self.tree = tree
        self.edge_length_mode: EdgeLengthMode = (
            EdgeLengthMode(edge_length_mode)
            if not isinstance(edge_length_mode, EdgeLengthMode)
            else edge_length_mode
        )
        self.edge_length_attr = edge_length_attr
        self.layout_mode: LayoutMode = (
            LayoutMode(layout_mode) if not isinstance(layout_mode, LayoutMode) else layout_mode
        )
        self.node_rank_mode: NodeRankMode = (
            NodeRankMode(node_rank_mode)
            if not isinstance(node_rank_mode, NodeRankMode)
            else node_rank_mode
        )

        self.depths: dict[TreeNode, float] = {}
        self.leaf_ranks: dict[TreeNode, float] = {}
        self.max_depth: float = 0.0
        self.leaf_count: int = 0
        self.rank_span: int = 0

        self.positions: dict[TreeNode, tuple[float, float]] = {}
        self.label_angle: dict[TreeNode, float] = {}
        self.label_ha: dict[TreeNode, str] = {}
        self.label_va: dict[TreeNode, str] = {}

        self._compute()

    # ------------------------------------------------------------------
    # Public helpers
    # ------------------------------------------------------------------

    def parent_edge(self, node: TreeNode) -> tuple[tuple[float, float], tuple[float, float]] | None:
        """Return the screen-space endpoints of the edge from a node to its parent.

        For ``CIRCULAR`` mode the returned coordinates are straight-line Cartesian endpoints;
        renderers are responsible for drawing the elbow (radial segment + arc).

        Args:
            node: The node whose parent edge is requested.

        Returns:
            A tuple ``(parent_pos, node_pos)`` of ``(x, y)`` coordinate pairs, or ``None`` if
                *node* is the root.
        """
        if node.parent is None:
            return None

        return self.positions[node.parent], self.positions[node]

    def children_connector(
        self, node: TreeNode
    ) -> tuple[tuple[float, float], tuple[float, float]] | None:
        """Return the screen-space endpoints of the connector between a node's outermost children.

        In ``HORIZONTAL`` mode this is a vertical line segment; in ``VERTICAL`` mode a horizontal
        segment. In ``CIRCULAR`` mode the two endpoints on the arc at the parent radius are
        returned — renderers should draw a circular arc rather than a straight line.

        Args:
            node: The internal node whose child connector is requested.

        Returns:
            A tuple ``(start, end)`` of ``(x, y)`` coordinate pairs, or ``None`` if *node* is a
            leaf.
        """
        if not node.children:
            return None

        first = list(node.children)[0]
        last = list(node.children)[-1]

        if self.layout_mode is LayoutMode.HORIZONTAL:
            x = self.positions[node][0]
            return (x, self.positions[first][1]), (x, self.positions[last][1])
        elif self.layout_mode is LayoutMode.VERTICAL:
            y = self.positions[node][1]
            return (self.positions[first][0], y), (self.positions[last][0], y)
        else:  # CIRCULAR — arc endpoints at parent radius
            r = self.depths[node]
            theta_first = self._theta(self.leaf_ranks[first])
            theta_last = self._theta(self.leaf_ranks[last])
            return (
                (r * math.cos(theta_first), r * math.sin(theta_first)),
                (r * math.cos(theta_last), r * math.sin(theta_last)),
            )

    def ghost_segment(
        self, node: TreeNode
    ) -> tuple[tuple[float, float], tuple[float, float]] | None:
        """Return the screen-space endpoints of the ghost (leaf-extension) segment for a node.

        A ghost segment is a dashed or otherwise styled line that extends a short-branched leaf
        to the maximum depth so that all leaves are visually aligned. It is only relevant for edge
        length modes where leaves can end at different depths (``ATTR`` or ``UNIFORM``). It is the
        responsibility of renderers to decide whether to draw a ghost segment.

        Args:
            node: The node for which the ghost segment is requested.

        Returns:
            A tuple ``(start, end)`` of ``(x, y)`` coordinate pairs representing the extension from
                the actual leaf position to the maximum depth, or ``None`` if *node* is not a leaf,
                or the leaf is already at :attr:`max_depth`.
        """
        if node.children:
            return None
        if self.max_depth - self.depths[node] < 1e-12:
            return None

        if self.layout_mode is LayoutMode.HORIZONTAL:
            x0, y = self.positions[node]
            return (x0, y), (self.max_depth, y)
        elif self.layout_mode is LayoutMode.VERTICAL:
            x, y0 = self.positions[node]
            return (x, y0), (x, self.max_depth)
        else:  # CIRCULAR
            theta = self._theta(self.leaf_ranks[node])
            r0 = self.depths[node]
            r1 = self.max_depth
            return (
                (r0 * math.cos(theta), r0 * math.sin(theta)),
                (r1 * math.cos(theta), r1 * math.sin(theta)),
            )

    def compute_positions(
        self,
        layout_mode: LayoutMode | Literal["horizontal", "vertical", "circular"] | None = None,
    ) -> None:
        """Transform canonical coordinates into screen-space positions.

        Called automatically during construction. Can be called again to switch layout modes
        without recomputing depths and leaf ranks.

        Args:
            layout_mode: Override the layout mode set during construction. Accepts a
                :class:`LayoutMode` member or its string value. If ``None``, the instance's
                current :attr:`layout_mode` is used.
        """
        if layout_mode is not None:
            self.layout_mode = (
                LayoutMode(layout_mode) if not isinstance(layout_mode, LayoutMode) else layout_mode
            )

        if self.layout_mode is LayoutMode.HORIZONTAL:
            self._positions_horizontal()
        elif self.layout_mode is LayoutMode.VERTICAL:
            self._positions_vertical()
        elif self.layout_mode is LayoutMode.CIRCULAR:
            self._positions_circular()

    # ------------------------------------------------------------------
    # Internal computation
    # ------------------------------------------------------------------

    def _compute(self) -> None:
        """Run the full layout pipeline."""
        if not self.tree.root:
            return

        self._compute_depths()
        self._compute_leaf_ranks()
        self.compute_positions()

    def _compute_depths(self) -> None:
        """Dispatch depth computation to the method matching :attr:`edge_length_mode`."""
        mode = self.edge_length_mode

        if mode is EdgeLengthMode.ATTR:
            self._depths_from_attr()
        elif mode is EdgeLengthMode.UNIFORM:
            self._depths_uniform()
        elif mode is EdgeLengthMode.EVEN:
            self._depths_even()
        elif mode is EdgeLengthMode.RANK:
            self._depths_rank()

    def _depths_from_attr(self) -> None:
        """Compute depths using a per-node attribute as the edge length."""
        attr = self.edge_length_attr
        for v in self.tree.preorder():
            if v.parent is None:
                self.depths[v] = 0.0
            else:
                self.depths[v] = self.depths[v.parent] + float(getattr(v, attr, 0.0))

        self.max_depth = max(self.depths.values()) if self.depths else 0.0

    def _depths_uniform(self) -> None:
        """Compute depths with every edge having length 1."""
        for v in self.tree.preorder():
            if v.parent is None:
                self.depths[v] = 0.0
            else:
                self.depths[v] = self.depths[v.parent] + 1.0

        self.max_depth = max(self.depths.values()) if self.depths else 0.0

    def _depths_even(self) -> None:
        """Compute depths so that all leaves end at depth 1 (cladogram style).

        For each internal node *v* with parent at depth *d*, the assigned depth is::

            depth[v] = d + (1 - d) / (subtree_height[v] + 1)

        where ``subtree_height[v]`` is the number of edges to the deepest leaf in *v*'s subtree.
        This distributes the remaining depth evenly among the remaining edges on the deepest
        root-to-leaf path through *v*, guaranteeing that every leaf reaches depth 1.
        """
        # Pass 1 (post-order): height of each subtree (edges to the deepest leaf).
        subtree_height: dict[TreeNode, int] = {}
        for v in self.tree.postorder():
            if not v.children:
                subtree_height[v] = 0
            else:
                subtree_height[v] = 1 + max(subtree_height[c] for c in v.children)

        # Pass 2 (pre-order): assign depths.
        for v in self.tree.preorder():
            if v.parent is None:
                self.depths[v] = 0.0
            elif subtree_height[v] == 0:
                self.depths[v] = 1.0
            else:
                self.depths[v] = self.depths[v.parent] + (1.0 - self.depths[v.parent]) / (
                    subtree_height[v] + 1
                )

        self.max_depth = max(self.depths.values()) if self.depths else 0.0

    def _depths_rank(self) -> None:
        """Compute depths using topological depth; extend leaves to the maximum depth."""
        topo_max = 0.0
        for v in self.tree.preorder():
            if v.parent is None:
                self.depths[v] = 0.0
            else:
                self.depths[v] = self.depths[v.parent] + 1.0
            if self.depths[v] > topo_max:
                topo_max = self.depths[v]

        for v in self.tree.leaves():
            self.depths[v] = topo_max

        self.max_depth = topo_max

    def _compute_leaf_ranks(self) -> None:
        """Assign perpendicular ranks to every node.

        Leaves receive consecutive integer ranks in left-to-right order. The rank of an internal
        node is determined by :attr:`node_rank_mode`.  In ``NODE`` mode every node (leaf and
        internal) receives its own unique rank in preorder, so all nodes are spaced equally along
        the perpendicular axis with no two nodes sharing a position.
        """
        if self.node_rank_mode is NodeRankMode.NODE:
            for index, v in enumerate(self.tree.preorder()):
                self.leaf_ranks[v] = float(index)
            self.leaf_count = sum(1 for _ in self.tree.leaves())
            self.rank_span = sum(1 for _ in self.tree.preorder())
            return

        leaf_index = 0
        for v in self.tree.postorder():
            if not v.children:
                self.leaf_ranks[v] = float(leaf_index)
                leaf_index += 1
            else:
                first = list(v.children)[0]
                last = list(v.children)[-1]
                if self.node_rank_mode is NodeRankMode.MEAN:
                    self.leaf_ranks[v] = (self.leaf_ranks[first] + self.leaf_ranks[last]) / 2.0
                elif self.node_rank_mode is NodeRankMode.FIRST:
                    self.leaf_ranks[v] = self.leaf_ranks[first]
                else:  # LAST
                    self.leaf_ranks[v] = self.leaf_ranks[last]

        self.leaf_count = leaf_index
        self.rank_span = leaf_index

    def _positions_horizontal(self) -> None:
        """Set positions for horizontal layout (root left, leaves right)."""
        for v in self.tree.preorder():
            self.positions[v] = (self.depths[v], self.leaf_ranks[v])
            self.label_angle[v] = 0.0
            self.label_ha[v] = "left"
            self.label_va[v] = "center"

    def _positions_vertical(self) -> None:
        """Set positions for vertical layout (root top, leaves bottom)."""
        for v in self.tree.preorder():
            self.positions[v] = (self.leaf_ranks[v], self.depths[v])
            self.label_angle[v] = -90.0
            self.label_ha[v] = "left"
            self.label_va[v] = "center"

    def _positions_circular(self) -> None:
        """Set positions for circular layout (root centre, leaves outside)."""
        for v in self.tree.preorder():
            r = self.depths[v]
            theta = self._theta(self.leaf_ranks[v])
            self.positions[v] = (r * math.cos(theta), r * math.sin(theta))
            angle_deg = math.degrees(theta)
            # Normalise to (-180, 180].
            angle_deg = (angle_deg + 180.0) % 360.0 - 180.0
            self.label_angle[v] = angle_deg
            # Labels on the right half point rightward; labels on the left half are flipped.
            if -90.0 <= angle_deg <= 90.0:
                self.label_ha[v] = "left"
            else:
                self.label_ha[v] = "right"
            self.label_va[v] = "center"

    def _theta(self, rank: float) -> float:
        """Convert a leaf rank to an angle in radians for circular layout.

        Angles run from 0 to just below 2π, distributed evenly across rank slots.

        Args:
            rank: The leaf rank to convert.

        Returns:
            The corresponding angle in radians.
        """
        if self.rank_span <= 1:
            return 0.0

        return 2.0 * math.pi * rank / self.rank_span

__init__(tree: Tree, edge_length_mode: EdgeLengthMode | Literal['attr', 'uniform', 'even', 'rank'] = EdgeLengthMode.ATTR, edge_length_attr: str = 'dist', layout_mode: LayoutMode | Literal['horizontal', 'vertical', 'circular'] = LayoutMode.HORIZONTAL, node_rank_mode: NodeRankMode | Literal['mean', 'first', 'last', 'node'] = NodeRankMode.MEAN) -> None

Construct a layout for the given tree.

Parameters:

Name Type Description Default
tree Tree

The tree to lay out.

required
edge_length_mode EdgeLengthMode | Literal['attr', 'uniform', 'even', 'rank']

How edge lengths along the depth axis are determined. Accepts an :class:EdgeLengthMode member or its string value ("attr", "uniform", "even", "rank").

ATTR
edge_length_attr str

Name of the :class:~tralda.datastructures.TreeNode attribute used as the edge length when edge_length_mode is ATTR. Default is "dist".

'dist'
layout_mode LayoutMode | Literal['horizontal', 'vertical', 'circular']

Orientation of the resulting drawing. Accepts a :class:LayoutMode member or its string value ("horizontal", "vertical", "circular").

HORIZONTAL
node_rank_mode NodeRankMode | Literal['mean', 'first', 'last', 'node']

How the perpendicular rank of internal nodes is derived from their children. Accepts a :class:NodeRankMode member or its string value ("mean", "first", "last", "node").

MEAN
Source code in tralda/visualization/layout.py
def __init__(
    self,
    tree: Tree,
    edge_length_mode: EdgeLengthMode
    | Literal["attr", "uniform", "even", "rank"] = EdgeLengthMode.ATTR,
    edge_length_attr: str = "dist",
    layout_mode: LayoutMode
    | Literal["horizontal", "vertical", "circular"] = LayoutMode.HORIZONTAL,
    node_rank_mode: NodeRankMode | Literal["mean", "first", "last", "node"] = NodeRankMode.MEAN,
) -> None:
    """Construct a layout for the given tree.

    Args:
        tree: The tree to lay out.
        edge_length_mode: How edge lengths along the depth axis are determined. Accepts an
            :class:`EdgeLengthMode` member or its string value (``"attr"``, ``"uniform"``,
            ``"even"``, ``"rank"``).
        edge_length_attr: Name of the :class:`~tralda.datastructures.TreeNode` attribute used
            as the edge length when *edge_length_mode* is ``ATTR``. Default is ``"dist"``.
        layout_mode: Orientation of the resulting drawing. Accepts a :class:`LayoutMode` member
            or its string value (``"horizontal"``, ``"vertical"``, ``"circular"``).
        node_rank_mode: How the perpendicular rank of internal nodes is derived from their
            children. Accepts a :class:`NodeRankMode` member or its string value
            (``"mean"``, ``"first"``, ``"last"``, ``"node"``).
    """
    self.tree = tree
    self.edge_length_mode: EdgeLengthMode = (
        EdgeLengthMode(edge_length_mode)
        if not isinstance(edge_length_mode, EdgeLengthMode)
        else edge_length_mode
    )
    self.edge_length_attr = edge_length_attr
    self.layout_mode: LayoutMode = (
        LayoutMode(layout_mode) if not isinstance(layout_mode, LayoutMode) else layout_mode
    )
    self.node_rank_mode: NodeRankMode = (
        NodeRankMode(node_rank_mode)
        if not isinstance(node_rank_mode, NodeRankMode)
        else node_rank_mode
    )

    self.depths: dict[TreeNode, float] = {}
    self.leaf_ranks: dict[TreeNode, float] = {}
    self.max_depth: float = 0.0
    self.leaf_count: int = 0
    self.rank_span: int = 0

    self.positions: dict[TreeNode, tuple[float, float]] = {}
    self.label_angle: dict[TreeNode, float] = {}
    self.label_ha: dict[TreeNode, str] = {}
    self.label_va: dict[TreeNode, str] = {}

    self._compute()

parent_edge(node: TreeNode) -> tuple[tuple[float, float], tuple[float, float]] | None

Return the screen-space endpoints of the edge from a node to its parent.

For CIRCULAR mode the returned coordinates are straight-line Cartesian endpoints; renderers are responsible for drawing the elbow (radial segment + arc).

Parameters:

Name Type Description Default
node TreeNode

The node whose parent edge is requested.

required

Returns:

Type Description
tuple[tuple[float, float], tuple[float, float]] | None

A tuple (parent_pos, node_pos) of (x, y) coordinate pairs, or None if node is the root.

Source code in tralda/visualization/layout.py
def parent_edge(self, node: TreeNode) -> tuple[tuple[float, float], tuple[float, float]] | None:
    """Return the screen-space endpoints of the edge from a node to its parent.

    For ``CIRCULAR`` mode the returned coordinates are straight-line Cartesian endpoints;
    renderers are responsible for drawing the elbow (radial segment + arc).

    Args:
        node: The node whose parent edge is requested.

    Returns:
        A tuple ``(parent_pos, node_pos)`` of ``(x, y)`` coordinate pairs, or ``None`` if
            *node* is the root.
    """
    if node.parent is None:
        return None

    return self.positions[node.parent], self.positions[node]

children_connector(node: TreeNode) -> tuple[tuple[float, float], tuple[float, float]] | None

Return the screen-space endpoints of the connector between a node's outermost children.

In HORIZONTAL mode this is a vertical line segment; in VERTICAL mode a horizontal segment. In CIRCULAR mode the two endpoints on the arc at the parent radius are returned — renderers should draw a circular arc rather than a straight line.

Parameters:

Name Type Description Default
node TreeNode

The internal node whose child connector is requested.

required

Returns:

Type Description
tuple[tuple[float, float], tuple[float, float]] | None

A tuple (start, end) of (x, y) coordinate pairs, or None if node is a

tuple[tuple[float, float], tuple[float, float]] | None

leaf.

Source code in tralda/visualization/layout.py
def children_connector(
    self, node: TreeNode
) -> tuple[tuple[float, float], tuple[float, float]] | None:
    """Return the screen-space endpoints of the connector between a node's outermost children.

    In ``HORIZONTAL`` mode this is a vertical line segment; in ``VERTICAL`` mode a horizontal
    segment. In ``CIRCULAR`` mode the two endpoints on the arc at the parent radius are
    returned — renderers should draw a circular arc rather than a straight line.

    Args:
        node: The internal node whose child connector is requested.

    Returns:
        A tuple ``(start, end)`` of ``(x, y)`` coordinate pairs, or ``None`` if *node* is a
        leaf.
    """
    if not node.children:
        return None

    first = list(node.children)[0]
    last = list(node.children)[-1]

    if self.layout_mode is LayoutMode.HORIZONTAL:
        x = self.positions[node][0]
        return (x, self.positions[first][1]), (x, self.positions[last][1])
    elif self.layout_mode is LayoutMode.VERTICAL:
        y = self.positions[node][1]
        return (self.positions[first][0], y), (self.positions[last][0], y)
    else:  # CIRCULAR — arc endpoints at parent radius
        r = self.depths[node]
        theta_first = self._theta(self.leaf_ranks[first])
        theta_last = self._theta(self.leaf_ranks[last])
        return (
            (r * math.cos(theta_first), r * math.sin(theta_first)),
            (r * math.cos(theta_last), r * math.sin(theta_last)),
        )

ghost_segment(node: TreeNode) -> tuple[tuple[float, float], tuple[float, float]] | None

Return the screen-space endpoints of the ghost (leaf-extension) segment for a node.

A ghost segment is a dashed or otherwise styled line that extends a short-branched leaf to the maximum depth so that all leaves are visually aligned. It is only relevant for edge length modes where leaves can end at different depths (ATTR or UNIFORM). It is the responsibility of renderers to decide whether to draw a ghost segment.

Parameters:

Name Type Description Default
node TreeNode

The node for which the ghost segment is requested.

required

Returns:

Type Description
tuple[tuple[float, float], tuple[float, float]] | None

A tuple (start, end) of (x, y) coordinate pairs representing the extension from the actual leaf position to the maximum depth, or None if node is not a leaf, or the leaf is already at :attr:max_depth.

Source code in tralda/visualization/layout.py
def ghost_segment(
    self, node: TreeNode
) -> tuple[tuple[float, float], tuple[float, float]] | None:
    """Return the screen-space endpoints of the ghost (leaf-extension) segment for a node.

    A ghost segment is a dashed or otherwise styled line that extends a short-branched leaf
    to the maximum depth so that all leaves are visually aligned. It is only relevant for edge
    length modes where leaves can end at different depths (``ATTR`` or ``UNIFORM``). It is the
    responsibility of renderers to decide whether to draw a ghost segment.

    Args:
        node: The node for which the ghost segment is requested.

    Returns:
        A tuple ``(start, end)`` of ``(x, y)`` coordinate pairs representing the extension from
            the actual leaf position to the maximum depth, or ``None`` if *node* is not a leaf,
            or the leaf is already at :attr:`max_depth`.
    """
    if node.children:
        return None
    if self.max_depth - self.depths[node] < 1e-12:
        return None

    if self.layout_mode is LayoutMode.HORIZONTAL:
        x0, y = self.positions[node]
        return (x0, y), (self.max_depth, y)
    elif self.layout_mode is LayoutMode.VERTICAL:
        x, y0 = self.positions[node]
        return (x, y0), (x, self.max_depth)
    else:  # CIRCULAR
        theta = self._theta(self.leaf_ranks[node])
        r0 = self.depths[node]
        r1 = self.max_depth
        return (
            (r0 * math.cos(theta), r0 * math.sin(theta)),
            (r1 * math.cos(theta), r1 * math.sin(theta)),
        )

compute_positions(layout_mode: LayoutMode | Literal['horizontal', 'vertical', 'circular'] | None = None) -> None

Transform canonical coordinates into screen-space positions.

Called automatically during construction. Can be called again to switch layout modes without recomputing depths and leaf ranks.

Parameters:

Name Type Description Default
layout_mode LayoutMode | Literal['horizontal', 'vertical', 'circular'] | None

Override the layout mode set during construction. Accepts a :class:LayoutMode member or its string value. If None, the instance's current :attr:layout_mode is used.

None
Source code in tralda/visualization/layout.py
def compute_positions(
    self,
    layout_mode: LayoutMode | Literal["horizontal", "vertical", "circular"] | None = None,
) -> None:
    """Transform canonical coordinates into screen-space positions.

    Called automatically during construction. Can be called again to switch layout modes
    without recomputing depths and leaf ranks.

    Args:
        layout_mode: Override the layout mode set during construction. Accepts a
            :class:`LayoutMode` member or its string value. If ``None``, the instance's
            current :attr:`layout_mode` is used.
    """
    if layout_mode is not None:
        self.layout_mode = (
            LayoutMode(layout_mode) if not isinstance(layout_mode, LayoutMode) else layout_mode
        )

    if self.layout_mode is LayoutMode.HORIZONTAL:
        self._positions_horizontal()
    elif self.layout_mode is LayoutMode.VERTICAL:
        self._positions_vertical()
    elif self.layout_mode is LayoutMode.CIRCULAR:
        self._positions_circular()

EdgeLengthMode

Bases: Enum

How edge lengths are determined along the depth axis.

Attributes:

Name Type Description
ATTR

Use a node attribute for the length of the edge to its parent.

UNIFORM

Every edge has length 1. Leaves may end at different depths.

EVEN

Edges are scaled so that all leaves end at the same depth (cladogram style). Each internal node is placed by distributing the remaining depth evenly among the remaining edges on the deepest root-to-leaf path through that node.

RANK

Internal nodes are placed at their topological depth (number of edges from the root). Leaves are extended to the maximum depth so they are all aligned.

Source code in tralda/visualization/layout.py
class EdgeLengthMode(Enum):
    """How edge lengths are determined along the depth axis.

    Attributes:
        ATTR: Use a node attribute for the length of the edge to its parent.
        UNIFORM: Every edge has length 1. Leaves may end at different depths.
        EVEN: Edges are scaled so that all leaves end at the same depth (cladogram style). Each
            internal node is placed by distributing the remaining depth evenly among the remaining
            edges on the deepest root-to-leaf path through that node.
        RANK: Internal nodes are placed at their topological depth (number of edges from the root).
            Leaves are extended to the maximum depth so they are all aligned.
    """

    ATTR = "attr"
    UNIFORM = "uniform"
    EVEN = "even"
    RANK = "rank"

LayoutMode

Bases: Enum

Orientation of the tree drawing.

Attributes:

Name Type Description
HORIZONTAL

Root on the left, leaves on the right.

VERTICAL

Root at the top, leaves at the bottom.

CIRCULAR

Root at the centre, leaves on the outside.

Source code in tralda/visualization/layout.py
class LayoutMode(Enum):
    """Orientation of the tree drawing.

    Attributes:
        HORIZONTAL: Root on the left, leaves on the right.
        VERTICAL: Root at the top, leaves at the bottom.
        CIRCULAR: Root at the centre, leaves on the outside.
    """

    HORIZONTAL = "horizontal"
    VERTICAL = "vertical"
    CIRCULAR = "circular"

NodeRankMode

Bases: Enum

How the perpendicular rank of an internal node is derived from its children's ranks.

Attributes:

Name Type Description
MEAN

The node is placed at the mean rank of its first and last child (default).

FIRST

The node is placed at the rank of its first (leftmost) child.

LAST

The node is placed at the rank of its last (rightmost) child.

NODE

Every node (leaf or internal) receives its own unique rank in preorder. This guarantees that no two nodes share the same perpendicular position, which eliminates label overlap when labels are drawn for all nodes.

Source code in tralda/visualization/layout.py
class NodeRankMode(Enum):
    """How the perpendicular rank of an internal node is derived from its children's ranks.

    Attributes:
        MEAN: The node is placed at the mean rank of its first and last child (default).
        FIRST: The node is placed at the rank of its first (leftmost) child.
        LAST: The node is placed at the rank of its last (rightmost) child.
        NODE: Every node (leaf or internal) receives its own unique rank in preorder.  This
            guarantees that no two nodes share the same perpendicular position, which eliminates
            label overlap when labels are drawn for all nodes.
    """

    MEAN = "mean"
    FIRST = "first"
    LAST = "last"
    NODE = "node"

Styling

NodeStyle dataclass

Partial style specification for a single tree node.

Every field defaults to None, which means "inherit from the layer below". Pass a :class:NodeStyle from a style function or node-override dict to override only the fields you care about.

Symbol fields

symbol Name of the symbol to draw (must be registered in :data:~._symbols.SYMBOL_REGISTRY). symbol_size Diameter of the symbol in points. symbol_color Fill color of the symbol (passed as color to the drawer). symbol_edge_color Border color of the symbol (passed as edge_color to the drawer). symbol_lw Border line width of the symbol in points. symbol_zorder Z-order of the symbol layer.

Incoming-edge fields (edge from parent to this node)

edge_color Color of the edge from this node's parent. edge_lw Line width of the incoming edge in points. edge_ls Line style of the incoming edge (e.g. "-", "--", ":", "-.").

Label fields

label_color Color of the leaf label text. label_fontsize Font size in points, or a named size string (e.g. "small"). label_fontweight Font weight: "normal" or "bold". label_fontstyle Font style: "normal" or "italic".

Source code in tralda/visualization/style.py
@dataclass
class NodeStyle:
    """Partial style specification for a single tree node.

    Every field defaults to ``None``, which means "inherit from the layer below".  Pass a
    :class:`NodeStyle` from a style function or node-override dict to override only the fields you
    care about.

    **Symbol fields**

    symbol
        Name of the symbol to draw (must be registered in :data:`~._symbols.SYMBOL_REGISTRY`).
    symbol_size
        Diameter of the symbol in points.
    symbol_color
        Fill color of the symbol (passed as *color* to the drawer).
    symbol_edge_color
        Border color of the symbol (passed as *edge_color* to the drawer).
    symbol_lw
        Border line width of the symbol in points.
    symbol_zorder
        Z-order of the symbol layer.

    **Incoming-edge fields (edge from parent to this node)**

    edge_color
        Color of the edge from this node's parent.
    edge_lw
        Line width of the incoming edge in points.
    edge_ls
        Line style of the incoming edge (e.g. ``"-"``, ``"--"``, ``":"``, ``"-.\"``).

    **Label fields**

    label_color
        Color of the leaf label text.
    label_fontsize
        Font size in points, or a named size string (e.g. ``"small"``).
    label_fontweight
        Font weight: ``"normal"`` or ``"bold"``.
    label_fontstyle
        Font style: ``"normal"`` or ``"italic"``.
    """

    # symbol
    symbol: str | None = None
    symbol_size: float | None = None
    symbol_color: Any = None
    symbol_edge_color: str | None = None
    symbol_lw: float | None = None
    symbol_zorder: int | None = None
    # incoming edge
    edge_color: Any | None = None
    edge_lw: float | None = None
    edge_ls: str | None = None
    # label
    label_color: Any | None = None
    label_fontsize: float | str | None = None
    label_fontweight: str | None = None
    label_fontstyle: str | None = None

    def overlay(self, other: NodeStyle) -> NodeStyle:
        """Return a new :class:`NodeStyle` with *other*'s non-``None`` fields applied over self.

        Args:
            other: The higher-priority partial style.

        Returns:
            A new :class:`NodeStyle` merging both layers.
        """
        merged = NodeStyle(**{f.name: getattr(self, f.name) for f in fields(self)})
        for f in fields(other):
            v = getattr(other, f.name)
            if v is not None:
                setattr(merged, f.name, v)
        return merged

overlay(other: NodeStyle) -> NodeStyle

Return a new :class:NodeStyle with other's non-None fields applied over self.

Parameters:

Name Type Description Default
other NodeStyle

The higher-priority partial style.

required

Returns:

Type Description
NodeStyle

A new :class:NodeStyle merging both layers.

Source code in tralda/visualization/style.py
def overlay(self, other: NodeStyle) -> NodeStyle:
    """Return a new :class:`NodeStyle` with *other*'s non-``None`` fields applied over self.

    Args:
        other: The higher-priority partial style.

    Returns:
        A new :class:`NodeStyle` merging both layers.
    """
    merged = NodeStyle(**{f.name: getattr(self, f.name) for f in fields(self)})
    for f in fields(other):
        v = getattr(other, f.name)
        if v is not None:
            setattr(merged, f.name, v)
    return merged

TreeStyle dataclass

Resolved styling configuration for an entire tree.

Attributes:

Name Type Description
default NodeStyle

Default :class:NodeStyle applied to every node before any per-node overrides. Pass a partial :class:NodeStyle (only the fields you want to change) and it will be overlaid onto :data:DEFAULT_NODE_STYLE in __post_init__, so omitted fields keep their standard values. After construction, default is always fully resolved (no None fields).

node_symbol str

Symbol name used for all nodes when the style function and node-override dict do not specify one. Default "none" (i.e. no symbol).

root_symbol str | None

Symbol name used for the root node when the style function and node-override dict do not specify one. Default None (falls back to node_symbol).

leaf_symbol str | None

Symbol name used for leaf nodes when not otherwise specified. Default None (falls back to node_symbol).

internal_symbol str | None

Symbol name used for internal (non-root) nodes when not otherwise specified. Default None (falls back to node_symbol).

ghost_color str

Color of ghost (leaf-extension) segments. Default "grey".

ghost_lw float

Line width of ghost segments. Default 0.6.

ghost_ls str

Line style of ghost segments. Default "--".

style_fn StyleFn | None

Optional callable (node, layout_mode) -> NodeStyle | None called once per node. Return None or a partial :class:NodeStyle; only non-None fields override the default.

node_overrides dict[TreeNode, NodeStyle]

Optional per-node overrides applied after style_fn (highest priority).

Source code in tralda/visualization/style.py
@dataclass
class TreeStyle:
    """Resolved styling configuration for an entire tree.

    Attributes:
        default: Default :class:`NodeStyle` applied to every node before any per-node overrides.
            Pass a *partial* :class:`NodeStyle` (only the fields you want to change) and it will
            be overlaid onto :data:`DEFAULT_NODE_STYLE` in ``__post_init__``, so omitted fields
            keep their standard values.  After construction, ``default`` is always fully resolved
            (no ``None`` fields).
        node_symbol: Symbol name used for all nodes when the style function and node-override dict
            do not specify one.  Default ``"none"`` (i.e. no symbol).
        root_symbol: Symbol name used for the root node when the style function and node-override
            dict do not specify one.  Default ``None`` (falls back to *node_symbol*).
        leaf_symbol: Symbol name used for leaf nodes when not otherwise specified.
            Default ``None`` (falls back to *node_symbol*).
        internal_symbol: Symbol name used for internal (non-root) nodes when not otherwise
            specified.  Default ``None`` (falls back to *node_symbol*).
        ghost_color: Color of ghost (leaf-extension) segments.  Default ``"grey"``.
        ghost_lw: Line width of ghost segments.  Default ``0.6``.
        ghost_ls: Line style of ghost segments.  Default ``"--"``.
        style_fn: Optional callable ``(node, layout_mode) -> NodeStyle | None`` called once per
            node.  Return ``None`` or a partial :class:`NodeStyle`; only non-``None`` fields
            override the default.
        node_overrides: Optional per-node overrides applied after *style_fn* (highest priority).
    """

    # ── tree-wide defaults ─────────────────────────────────────────────────────────────────────
    default: NodeStyle = field(default=None)  # type: ignore[assignment]  resolved in __post_init__

    # ── structural symbol fallbacks ────────────────────────────────────────────────────────────
    node_symbol: str = "none"  # default for all nodes when no other symbol is specified
    root_symbol: str | None = None
    leaf_symbol: str | None = None
    internal_symbol: str | None = None

    # ── ghost segment styling ──────────────────────────────────────────────────────────────────
    ghost_color: str = "grey"
    ghost_lw: float = 0.6
    ghost_ls: str = "--"

    # ── override layers ────────────────────────────────────────────────────────────────────────
    style_fn: StyleFn | None = None
    node_overrides: dict[TreeNode, NodeStyle] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.default is None:
            self.default = DEFAULT_NODE_STYLE.overlay(NodeStyle())  # fresh copy
        else:
            self.default = DEFAULT_NODE_STYLE.overlay(self.default)

    # ------------------------------------------------------------------------------------------
    # Resolution
    # ------------------------------------------------------------------------------------------

    def resolve(self, node: TreeNode, layout_mode: LayoutMode) -> NodeStyle:
        """Return the fully-resolved :class:`NodeStyle` for *node*.

        Applies the default, then the style function, then any direct node override.  The
        ``symbol`` field is filled from the structural fallbacks when it is still ``None`` after all
        layers have been merged.

        Args:
            node: The node to resolve styling for.
            layout_mode: Current layout mode, passed to :attr:`style_fn`.

        Returns:
            A :class:`NodeStyle` with no ``None`` fields.
        """
        style = self.default

        # Apply style function overrides.
        if self.style_fn is not None:
            override = self.style_fn(node, layout_mode)
            if override is not None:
                style = style.overlay(override)

        # Apply direct node overrides.
        if node in self.node_overrides:
            style = style.overlay(self.node_overrides[node])

        # Fill in structural symbol fallback if still unresolved.
        if style.symbol is None:
            if node.parent is None:
                sym = self.root_symbol
            elif not node.children:
                sym = self.leaf_symbol
            else:
                sym = self.internal_symbol

            # If the fallback is also None, fall back to node_symbol (which defaults to "none").
            if sym is None:
                sym = self.node_symbol

            style = style.overlay(NodeStyle(symbol=sym))

        return style

    def consensus_style(self, nodes: Iterable[TreeNode], layout_mode: LayoutMode) -> NodeStyle:
        """Return a consensus style for a set of nodes (with fallback to default).

        If all nodes agree on a particular field (e.g. all have the same symbol or symbol color),
        the consensus style will use that value.  If there's any disagreement, the consensus style
        falls back to the default for that field.  This is useful for styling internal nodes or
        edges based on the styles of their children (e.g. for styling the child connector line / arc
        based on the styles of the child nodes).

        Args:
            nodes: The nodes to find a consensus style for.
            layout_mode: Current layout mode, passed to :attr:`style_fn`.

        Returns:
            A :class:`NodeStyle` representing the consensus style.
        """
        consensus = self.default.overlay(NodeStyle())  # start with a fresh copy of the default

        styles = [self.resolve(n, layout_mode) for n in nodes]
        if not styles:
            return consensus

        # For each field, check if all styles agree on the same non-None value.  If so, use it in
        # the consensus; otherwise leave the default.
        for f in fields(NodeStyle):
            values = [getattr(s, f.name) for s in styles]
            if values[0] is not None and all(_safe_eq(v, values[0]) for v in values):
                setattr(consensus, f.name, values[0])

        return consensus

    # ------------------------------------------------------------------------------------------
    # Convenience constructors
    # ------------------------------------------------------------------------------------------

    @classmethod
    def from_maps(
        cls,
        *,
        style_attr: str = "style",
        style_map: dict[str, NodeStyle] | None = None,
        symbol_attr: str = "symbol",
        symbol_map: dict[str, str] | None = None,
        node_color_attr: str = "color",
        node_color_map: dict[Any, Any] | None = None,
        edge_color_attr: str = "edge_color",
        edge_color_map: dict[Any, Any] | None = None,
        **kwargs: Any,
    ) -> TreeStyle:
        """Construct a :class:`TreeStyle` from attribute lookup maps.

        This is a convenience constructor for the common case where styling is determined by
        looking up node attributes in provided maps.  It translates the maps into a style function
        that applies the specified lookups and falls back to the default styling when no match is
        found.  The kwargs are forwarded to the :class:`TreeStyle` constructor, so you can still
        set defaults and other options.

        If provided, *style_map* is applied first (lowest priority). The other maps may override
        symbols and colors specified in *style_map*.

        Args:
            style_attr: Node attribute whose value is looked up in *style_map* to get a full or
                partial :class:`NodeStyle` for the node.  Default ``"style"``.
            style_map: Mapping from *style_attr* values to :class:`NodeStyle` instances.
            symbol_attr: Node attribute whose value is looked up in *symbol_map*.
                Default ``"symbol"``.
            symbol_map: Mapping from *symbol_attr* values to symbol names.  Nodes not in the map
                use the structural fallbacks.
            node_color_attr: Node attribute whose value is looked up in *node_color_map*.
                Default ``"color"``.
            node_color_map: Mapping from *node_color_attr* values to fill colors.
            edge_color_attr: Node attribute whose value is looked up in *edge_color_map*.
                Default ``"edge_color"``.
            edge_color_map: Mapping from *edge_color_attr* values to edge colors.
            **kwargs: Forwarded to the :class:`TreeStyle` constructor (e.g. ``root_symbol``,
                ``ghost_color``, ``default`` for a custom base :class:`NodeStyle`, etc.).

        Returns:
            A new :class:`TreeStyle` with a style function encoding the provided maps.
        """
        _style_map: dict[str, NodeStyle] = style_map or {}
        _symbol_map: dict[str, str] = symbol_map or {}
        _node_col_map: dict[Any, Any] = node_color_map or {}
        _edge_col_map: dict[Any, Any] = edge_color_map or {}

        def _style_fn(node: TreeNode, _mode: LayoutMode) -> NodeStyle | None:
            ns = NodeStyle()

            # Style from style attribute.
            val = getattr(node, style_attr, None)
            if val is not None and val in _style_map:
                ns = ns.overlay(_style_map[val])

            # Symbol from symbol attribute.
            ev = getattr(node, symbol_attr, None)
            if ev is not None and ev in _symbol_map:
                ns.symbol = _symbol_map[ev]

            # Fill color from color attribute.
            key = getattr(node, node_color_attr, None)
            if key is not None and key in _node_col_map:
                ns.symbol_color = _node_col_map[key]

            # Edge color from edge color attribute.
            key = getattr(node, edge_color_attr, None)
            if key is not None and key in _edge_col_map:
                ns.edge_color = _edge_col_map[key]

            # Return None if nothing was customised (avoids a no-op overlay).
            if ns.symbol is None and ns.symbol_color is None and ns.edge_color is None:
                return None

            return ns

        return cls(style_fn=_style_fn, **kwargs)

resolve(node: TreeNode, layout_mode: LayoutMode) -> NodeStyle

Return the fully-resolved :class:NodeStyle for node.

Applies the default, then the style function, then any direct node override. The symbol field is filled from the structural fallbacks when it is still None after all layers have been merged.

Parameters:

Name Type Description Default
node TreeNode

The node to resolve styling for.

required
layout_mode LayoutMode

Current layout mode, passed to :attr:style_fn.

required

Returns:

Name Type Description
A NodeStyle

class:NodeStyle with no None fields.

Source code in tralda/visualization/style.py
def resolve(self, node: TreeNode, layout_mode: LayoutMode) -> NodeStyle:
    """Return the fully-resolved :class:`NodeStyle` for *node*.

    Applies the default, then the style function, then any direct node override.  The
    ``symbol`` field is filled from the structural fallbacks when it is still ``None`` after all
    layers have been merged.

    Args:
        node: The node to resolve styling for.
        layout_mode: Current layout mode, passed to :attr:`style_fn`.

    Returns:
        A :class:`NodeStyle` with no ``None`` fields.
    """
    style = self.default

    # Apply style function overrides.
    if self.style_fn is not None:
        override = self.style_fn(node, layout_mode)
        if override is not None:
            style = style.overlay(override)

    # Apply direct node overrides.
    if node in self.node_overrides:
        style = style.overlay(self.node_overrides[node])

    # Fill in structural symbol fallback if still unresolved.
    if style.symbol is None:
        if node.parent is None:
            sym = self.root_symbol
        elif not node.children:
            sym = self.leaf_symbol
        else:
            sym = self.internal_symbol

        # If the fallback is also None, fall back to node_symbol (which defaults to "none").
        if sym is None:
            sym = self.node_symbol

        style = style.overlay(NodeStyle(symbol=sym))

    return style

consensus_style(nodes: Iterable[TreeNode], layout_mode: LayoutMode) -> NodeStyle

Return a consensus style for a set of nodes (with fallback to default).

If all nodes agree on a particular field (e.g. all have the same symbol or symbol color), the consensus style will use that value. If there's any disagreement, the consensus style falls back to the default for that field. This is useful for styling internal nodes or edges based on the styles of their children (e.g. for styling the child connector line / arc based on the styles of the child nodes).

Parameters:

Name Type Description Default
nodes Iterable[TreeNode]

The nodes to find a consensus style for.

required
layout_mode LayoutMode

Current layout mode, passed to :attr:style_fn.

required

Returns:

Name Type Description
A NodeStyle

class:NodeStyle representing the consensus style.

Source code in tralda/visualization/style.py
def consensus_style(self, nodes: Iterable[TreeNode], layout_mode: LayoutMode) -> NodeStyle:
    """Return a consensus style for a set of nodes (with fallback to default).

    If all nodes agree on a particular field (e.g. all have the same symbol or symbol color),
    the consensus style will use that value.  If there's any disagreement, the consensus style
    falls back to the default for that field.  This is useful for styling internal nodes or
    edges based on the styles of their children (e.g. for styling the child connector line / arc
    based on the styles of the child nodes).

    Args:
        nodes: The nodes to find a consensus style for.
        layout_mode: Current layout mode, passed to :attr:`style_fn`.

    Returns:
        A :class:`NodeStyle` representing the consensus style.
    """
    consensus = self.default.overlay(NodeStyle())  # start with a fresh copy of the default

    styles = [self.resolve(n, layout_mode) for n in nodes]
    if not styles:
        return consensus

    # For each field, check if all styles agree on the same non-None value.  If so, use it in
    # the consensus; otherwise leave the default.
    for f in fields(NodeStyle):
        values = [getattr(s, f.name) for s in styles]
        if values[0] is not None and all(_safe_eq(v, values[0]) for v in values):
            setattr(consensus, f.name, values[0])

    return consensus

from_maps(*, style_attr: str = 'style', style_map: dict[str, NodeStyle] | None = None, symbol_attr: str = 'symbol', symbol_map: dict[str, str] | None = None, node_color_attr: str = 'color', node_color_map: dict[Any, Any] | None = None, edge_color_attr: str = 'edge_color', edge_color_map: dict[Any, Any] | None = None, **kwargs: Any) -> TreeStyle classmethod

Construct a :class:TreeStyle from attribute lookup maps.

This is a convenience constructor for the common case where styling is determined by looking up node attributes in provided maps. It translates the maps into a style function that applies the specified lookups and falls back to the default styling when no match is found. The kwargs are forwarded to the :class:TreeStyle constructor, so you can still set defaults and other options.

If provided, style_map is applied first (lowest priority). The other maps may override symbols and colors specified in style_map.

Parameters:

Name Type Description Default
style_attr str

Node attribute whose value is looked up in style_map to get a full or partial :class:NodeStyle for the node. Default "style".

'style'
style_map dict[str, NodeStyle] | None

Mapping from style_attr values to :class:NodeStyle instances.

None
symbol_attr str

Node attribute whose value is looked up in symbol_map. Default "symbol".

'symbol'
symbol_map dict[str, str] | None

Mapping from symbol_attr values to symbol names. Nodes not in the map use the structural fallbacks.

None
node_color_attr str

Node attribute whose value is looked up in node_color_map. Default "color".

'color'
node_color_map dict[Any, Any] | None

Mapping from node_color_attr values to fill colors.

None
edge_color_attr str

Node attribute whose value is looked up in edge_color_map. Default "edge_color".

'edge_color'
edge_color_map dict[Any, Any] | None

Mapping from edge_color_attr values to edge colors.

None
**kwargs Any

Forwarded to the :class:TreeStyle constructor (e.g. root_symbol, ghost_color, default for a custom base :class:NodeStyle, etc.).

{}

Returns:

Type Description
TreeStyle

A new :class:TreeStyle with a style function encoding the provided maps.

Source code in tralda/visualization/style.py
@classmethod
def from_maps(
    cls,
    *,
    style_attr: str = "style",
    style_map: dict[str, NodeStyle] | None = None,
    symbol_attr: str = "symbol",
    symbol_map: dict[str, str] | None = None,
    node_color_attr: str = "color",
    node_color_map: dict[Any, Any] | None = None,
    edge_color_attr: str = "edge_color",
    edge_color_map: dict[Any, Any] | None = None,
    **kwargs: Any,
) -> TreeStyle:
    """Construct a :class:`TreeStyle` from attribute lookup maps.

    This is a convenience constructor for the common case where styling is determined by
    looking up node attributes in provided maps.  It translates the maps into a style function
    that applies the specified lookups and falls back to the default styling when no match is
    found.  The kwargs are forwarded to the :class:`TreeStyle` constructor, so you can still
    set defaults and other options.

    If provided, *style_map* is applied first (lowest priority). The other maps may override
    symbols and colors specified in *style_map*.

    Args:
        style_attr: Node attribute whose value is looked up in *style_map* to get a full or
            partial :class:`NodeStyle` for the node.  Default ``"style"``.
        style_map: Mapping from *style_attr* values to :class:`NodeStyle` instances.
        symbol_attr: Node attribute whose value is looked up in *symbol_map*.
            Default ``"symbol"``.
        symbol_map: Mapping from *symbol_attr* values to symbol names.  Nodes not in the map
            use the structural fallbacks.
        node_color_attr: Node attribute whose value is looked up in *node_color_map*.
            Default ``"color"``.
        node_color_map: Mapping from *node_color_attr* values to fill colors.
        edge_color_attr: Node attribute whose value is looked up in *edge_color_map*.
            Default ``"edge_color"``.
        edge_color_map: Mapping from *edge_color_attr* values to edge colors.
        **kwargs: Forwarded to the :class:`TreeStyle` constructor (e.g. ``root_symbol``,
            ``ghost_color``, ``default`` for a custom base :class:`NodeStyle`, etc.).

    Returns:
        A new :class:`TreeStyle` with a style function encoding the provided maps.
    """
    _style_map: dict[str, NodeStyle] = style_map or {}
    _symbol_map: dict[str, str] = symbol_map or {}
    _node_col_map: dict[Any, Any] = node_color_map or {}
    _edge_col_map: dict[Any, Any] = edge_color_map or {}

    def _style_fn(node: TreeNode, _mode: LayoutMode) -> NodeStyle | None:
        ns = NodeStyle()

        # Style from style attribute.
        val = getattr(node, style_attr, None)
        if val is not None and val in _style_map:
            ns = ns.overlay(_style_map[val])

        # Symbol from symbol attribute.
        ev = getattr(node, symbol_attr, None)
        if ev is not None and ev in _symbol_map:
            ns.symbol = _symbol_map[ev]

        # Fill color from color attribute.
        key = getattr(node, node_color_attr, None)
        if key is not None and key in _node_col_map:
            ns.symbol_color = _node_col_map[key]

        # Edge color from edge color attribute.
        key = getattr(node, edge_color_attr, None)
        if key is not None and key in _edge_col_map:
            ns.edge_color = _edge_col_map[key]

        # Return None if nothing was customised (avoids a no-op overlay).
        if ns.symbol is None and ns.symbol_color is None and ns.edge_color is None:
            return None

        return ns

    return cls(style_fn=_style_fn, **kwargs)

DEFAULT_NODE_STYLE = NodeStyle(symbol=None, symbol_size=9.0, symbol_color='white', symbol_edge_color='black', symbol_lw=1.0, symbol_zorder=3, edge_color='black', edge_lw=1.0, edge_ls='-', label_color='black', label_fontsize=9, label_fontweight='normal', label_fontstyle='normal') module-attribute


Matplotlib backend

MatplotlibRenderer

Bases: BaseRenderer

Render a :class:~tralda.visualization.layout.TreeLayout with matplotlib.

All node symbols are drawn at a size specified in points, so they remain visually consistent regardless of the data-coordinate scale. Depth coordinates are optionally normalised to [0, 1] before rendering.

Pass a :class:~tralda.visualization.style.TreeStyle to control the appearance of every node. The style is resolved once per node during :meth:render via :meth:~tralda.visualization.style.TreeStyle.resolve, which merges the default style, an optional per-node style function, and optional direct node overrides in that order.

Example usage:

from tralda.visualization.layout import TreeLayout
from tralda.visualization.matplotlib_renderer import MatplotlibRenderer
from tralda.visualization.style import TreeStyle

layout = TreeLayout(tree, edge_length_mode="attr")
style = TreeStyle.from_maps(
    symbol_map={"S": "circle", "D": "square", "H": "triangle-up"},
    node_color_map={"a": "steelblue", "b": "tomato"},
)
renderer = MatplotlibRenderer(layout, tree_style=style)
fig, ax = renderer.render()
Source code in tralda/visualization/matplotlib_renderer/_renderer.py
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
class MatplotlibRenderer(BaseRenderer):
    """Render a :class:`~tralda.visualization.layout.TreeLayout` with matplotlib.

    All node symbols are drawn at a size specified in *points*, so they remain visually consistent
    regardless of the data-coordinate scale.  Depth coordinates are optionally normalised to
    ``[0, 1]`` before rendering.

    Pass a :class:`~tralda.visualization.style.TreeStyle` to control the appearance of every node.
    The style is resolved once per node during :meth:`render` via
    :meth:`~tralda.visualization.style.TreeStyle.resolve`, which merges the default style, an
    optional per-node style function, and optional direct node overrides in that order.

    Example usage:

        from tralda.visualization.layout import TreeLayout
        from tralda.visualization.matplotlib_renderer import MatplotlibRenderer
        from tralda.visualization.style import TreeStyle

        layout = TreeLayout(tree, edge_length_mode="attr")
        style = TreeStyle.from_maps(
            symbol_map={"S": "circle", "D": "square", "H": "triangle-up"},
            node_color_map={"a": "steelblue", "b": "tomato"},
        )
        renderer = MatplotlibRenderer(layout, tree_style=style)
        fig, ax = renderer.render()
    """

    def __init__(
        self,
        layout: TreeLayout,
        ax: Axes | None = None,
        *,
        tree_style: TreeStyle | None = None,
        rescale_depth: bool = True,
        show_labels: bool = True,
        show_internal_labels: bool = False,
        show_ghost_segments: bool = True,
        label_pad: float = 4.0,
        figsize: tuple[float, float] | None = None,
    ) -> None:
        """Construct a renderer.

        Args:
            layout: Pre-computed tree layout to render.
            ax: Existing :class:`~matplotlib.axes.Axes` to draw on.  When ``None`` (the default)
                a new figure and axes are created automatically when :meth:`render` is called.
            tree_style: Styling configuration for nodes, edges, and labels.  When ``None`` the
                default :class:`~tralda.visualization.style.TreeStyle` is used.
            rescale_depth: Normalise the depth axis to ``[0, 1]`` before rendering.  Recommended
                when edge lengths are in absolute units (e.g. millions of years).  Default ``True``.
            show_labels: Draw leaf labels.  Default ``True``.
            show_internal_labels: Also draw labels for internal nodes (including the root).  Only
                nodes that have a ``label`` attribute are labelled.  Default ``False``.
            show_ghost_segments: Extend short leaves to the maximum depth with a dashed line.  Only
                visible when leaves sit at different depths (``ATTR`` / ``UNIFORM`` edge-length
                modes).  Default ``True``.
            label_pad: Gap between the symbol edge and the start of the label text, in points.
                Default ``4``.
            figsize: Figure size ``(width, height)`` in inches.  When ``None`` the size is derived
                automatically from the leaf count and layout mode.
        """
        super().__init__(
            layout,
            tree_style=tree_style,
            rescale_depth=rescale_depth,
            show_labels=show_labels,
            show_internal_labels=show_internal_labels,
            show_ghost_segments=show_ghost_segments,
        )
        self._user_ax = ax
        self.label_pad = label_pad
        self.figsize = figsize

    # ----------------------------------------------------------------------------------------------
    # Public API
    # ----------------------------------------------------------------------------------------------

    def render(self) -> tuple[Figure, Axes]:
        """Draw the tree and return the figure and axes.

        When no *ax* was supplied to the constructor a new figure is created with an automatically
        derived size.  ``fig.tight_layout()`` is called automatically for new figures; when drawing
        on a caller-supplied axes the caller is responsible for layout management.

        Returns:
            ``(fig, ax)`` — the :class:`~matplotlib.figure.Figure` and
            :class:`~matplotlib.axes.Axes` the tree was drawn on.
        """
        new_fig = False
        if self._user_ax is not None:
            ax = self._user_ax
            fig = ax.get_figure()
        else:
            fig, ax = plt.subplots(figsize=self._auto_figsize())
            new_fig = True

        mode = self.layout.layout_mode
        positions = self._build_positions()

        # Resolve per-node styles once, reused by edge and node drawing passes.
        node_styles: dict[TreeNode, NodeStyle] = {
            v: self.tree_style.resolve(v, mode) for v in self.layout.tree.preorder()
        }

        self._draw_edges(ax, positions, node_styles)
        self._draw_nodes(ax, positions, node_styles, mode)
        if self.show_labels or self.show_internal_labels:
            self._draw_labels(ax, positions, node_styles)
        self._style_axes(ax)

        if new_fig:
            fig.tight_layout()

        return fig, ax

    # ----------------------------------------------------------------------------------------------
    # Edge drawing
    # ----------------------------------------------------------------------------------------------

    def _draw_edges(
        self,
        ax: Axes,
        positions: dict[TreeNode, tuple[float, float]],
        node_styles: dict[TreeNode, NodeStyle],
    ) -> None:
        """Draw all tree edges and optional ghost segments."""
        layout = self.layout
        mode = layout.layout_mode
        ts = self.tree_style

        for v in layout.tree.preorder():
            vx, vy = positions[v]
            ns = node_styles[v]

            # ── parent edge ────────────────────────────────────────────────────────────────────
            if v.parent is not None:
                px, py = positions[v.parent]
                x0, y0, x1, y1 = self._parent_edge_segment(vx, vy, px, py, mode)
                ax.plot(
                    [x0, x1],
                    [y0, y1],
                    color=ns.edge_color,
                    lw=ns.edge_lw,
                    ls=resolve_linestyle(ns.edge_ls),
                    solid_capstyle="round",
                )

            # ── child connector ────────────────────────────────────────────────────────────────
            if v.children:
                children, first_pos, last_pos = self._child_connector_range(v, positions)
                consensus_style = ts.consensus_style(children, mode)

                if mode is LayoutMode.HORIZONTAL:
                    ax.plot(
                        [vx, vx],
                        [first_pos[1], last_pos[1]],
                        color=consensus_style.edge_color,
                        lw=consensus_style.edge_lw,
                        ls=resolve_linestyle(consensus_style.edge_ls),
                        solid_capstyle="round",
                    )
                elif mode is LayoutMode.VERTICAL:
                    ax.plot(
                        [first_pos[0], last_pos[0]],
                        [vy, vy],
                        color=consensus_style.edge_color,
                        lw=consensus_style.edge_lw,
                        ls=resolve_linestyle(consensus_style.edge_ls),
                        solid_capstyle="round",
                    )
                else:
                    # CIRCULAR: arc at the node's radius spanning the outermost children's angles.
                    self._draw_arc(ax, vx, vy, first_pos, last_pos, consensus_style)

        # ── ghost segments ─────────────────────────────────────────────────────────────────────
        if self.show_ghost_segments:
            for v in layout.tree.leaves():
                seg = layout.ghost_segment(v)
                if seg is None:
                    continue
                (x0, y0), (x1, y1) = seg
                x0, y0, x1, y1 = self._rescale_seg(x0, y0, x1, y1, mode)
                ax.plot(
                    [x0, x1],
                    [y0, y1],
                    color=ts.ghost_color,
                    lw=ts.ghost_lw,
                    ls=resolve_linestyle(ts.ghost_ls),
                )

    def _draw_arc(
        self,
        ax: Axes,
        px: float,
        py: float,
        first_pos: tuple[float, float],
        last_pos: tuple[float, float],
        ns: NodeStyle,
    ) -> None:
        """Draw a circular arc at radius ``hypot(px, py)``.

        The arc spans from the angular projection of *first_pos* to that of *last_pos*, sweeping
        the shorter way around.

        Args:
            ax: The axes to draw on.
            px: X-coordinate of the node center, used to compute the arc radius.
            py: Y-coordinate of the node center, used to compute the arc radius.
            first_pos: (x, y) coordinates of the first child, used to determine the start angle.
            last_pos: (x, y) coordinates of the last child, used to determine the end angle.
            ns: The NodeStyle object containing styling information for the arc.
        """
        result = arc_theta_range(px, py, first_pos, last_pos)
        if result is None:
            return

        theta1, theta2, r, n_pts = result
        thetas = np.linspace(theta1, theta2, n_pts)
        ax.plot(
            r * np.cos(thetas),
            r * np.sin(thetas),
            color=ns.edge_color,
            lw=ns.edge_lw,
            ls=resolve_linestyle(ns.edge_ls),
            solid_capstyle="round",
        )

    # ----------------------------------------------------------------------------------------------
    # Node drawing
    # ----------------------------------------------------------------------------------------------

    def _draw_nodes(
        self,
        ax: Axes,
        positions: dict[TreeNode, tuple[float, float]],
        node_styles: dict[TreeNode, NodeStyle],
        mode: LayoutMode,
    ) -> None:
        """Draw a symbol at every node.

        Args:
            ax: The axes to draw on.
            positions: Mapping from tree nodes to (x, y) positions in data coordinates.
            node_styles: Mapping from tree nodes to resolved NodeStyle objects.
            mode: The layout mode, used to determine symbol rotation.
        """
        for v in self.layout.tree.preorder():
            ns = node_styles[v]
            drawer = SYMBOL_REGISTRY[ns.symbol]
            x, y = positions[v]

            if mode is LayoutMode.HORIZONTAL:
                angle = 0.0
            elif mode is LayoutMode.VERTICAL:
                angle = -90.0
            else:  # CIRCULAR: radial direction from the origin
                angle = math.degrees(math.atan2(y, x))

            drawer(ax, x, y, ns, angle=angle, layout_mode=mode)

    # ----------------------------------------------------------------------------------------------
    # Label drawing
    # ----------------------------------------------------------------------------------------------

    def _draw_labels(
        self,
        ax: Axes,
        positions: dict[TreeNode, tuple[float, float]],
        node_styles: dict[TreeNode, NodeStyle],
    ) -> None:
        """Draw a label next to every leaf (and optionally every internal node).

        Only nodes that have a ``label`` attribute will be annotated.

        Args:
            ax: The axes to draw on.
            positions: Mapping from tree nodes to (x, y) positions in data coordinates.
            node_styles: Mapping from tree nodes to resolved NodeStyle objects.
        """
        layout = self.layout
        mode = layout.layout_mode

        for v in layout.tree.preorder():
            if v.is_leaf() and not self.show_labels:
                continue
            if not v.is_leaf() and not self.show_internal_labels:
                continue

            label = getattr(v, "label", None)
            if label is None:
                continue

            ns = node_styles[v]
            offset_pts = ns.symbol_size / 2.0 + self.label_pad
            x, y = positions[v]

            x, y = self._ghost_label_anchor(v, x, y, mode)

            ha = layout.label_ha.get(v, "left")
            va = layout.label_va.get(v, "center")
            angle = layout.label_angle.get(v, 0.0)

            if mode is LayoutMode.HORIZONTAL:
                xytext = (offset_pts, 0.0)
            elif mode is LayoutMode.VERTICAL:
                # y-axis is inverted; use a negative offset to place text below the leaf symbol.
                xytext = (0.0, -offset_pts)
            else:
                # CIRCULAR: offset radially outward from the node.
                angle_rad = math.radians(angle)
                xytext = (math.cos(angle_rad) * offset_pts, math.sin(angle_rad) * offset_pts)
                # Flip text rotation on the left half so it reads away from the root.
                # Simple ±180 flip; ha is already set to "right" for left-half nodes by the layout.
                if not (-90.0 <= angle <= 90.0):
                    angle = angle - 180.0 if angle >= 0.0 else angle + 180.0

            ax.annotate(
                str(label),
                xy=(x, y),
                xytext=xytext,
                textcoords="offset points",
                fontsize=ns.label_fontsize,
                color=ns.label_color,
                fontweight=ns.label_fontweight,
                fontstyle=ns.label_fontstyle,
                ha=ha,
                va=va,
                rotation=angle,
                rotation_mode="anchor",
            )

    # ----------------------------------------------------------------------------------------------
    # Axis styling
    # ----------------------------------------------------------------------------------------------

    def _style_axes(self, ax: Axes) -> None:
        """Remove decorations and orient the axes for the chosen layout mode.

        Returns:
            The styled Axes object.
        """
        mode = self.layout.layout_mode
        n = self.layout.rank_span

        ax.set_xticks([])
        ax.set_yticks([])
        for spine in ax.spines.values():
            spine.set_visible(False)

        if mode is LayoutMode.HORIZONTAL:
            # Leaf rank 0 at the top, increasing downward.  Setting ylim explicitly avoids
            # matplotlib's default 5 % margin, which wastes significant vertical space for
            # large trees.  Half a rank unit of padding keeps the outermost symbols unclipped.
            # Skipped for an empty tree (n == 0), where low == high would be degenerate.
            if n > 0:
                ax.set_ylim(n - 0.5, -0.5)
        elif mode is LayoutMode.VERTICAL:
            # Depth 0 (root) at the top, increasing downward.
            ax.invert_yaxis()
            # Tighten the leaf-rank (x) axis for the same reason as the horizontal case.
            if n > 0:
                ax.set_xlim(-0.5, n - 0.5)
        else:
            # CIRCULAR: equal aspect ratio so the tree is not distorted.
            ax.set_aspect("equal")

    # ----------------------------------------------------------------------------------------------
    # Figure sizing
    # ----------------------------------------------------------------------------------------------

    def _auto_figsize(self) -> tuple[float, float]:
        """Compute a sensible default figure size from the leaf count.

        Returns:
            A tuple (width, height) representing the figure size in inches.
        """
        if self.figsize is not None:
            return self.figsize

        n = max(1, self.layout.rank_span)
        mode = self.layout.layout_mode
        default_symbol_size = self.tree_style.default.symbol_size

        if mode is LayoutMode.HORIZONTAL:
            return (8.0, max(3.0, n * 0.28))
        elif mode is LayoutMode.VERTICAL:
            return (max(3.0, n * 0.28), 7.0)
        else:
            # CIRCULAR: scale the figure so that adjacent leaf symbols have adequate arc spacing.
            # The effective plot circumference ≈ π × size_inches × 72 pts/inch × 0.8 (tight-layout
            # margin factor).  Setting arc-per-leaf ≥ 1.5 × symbol_size and solving for size:
            #   size = n × symbol_size × 1.5 / (π × 72 × 0.8)
            min_size = n * default_symbol_size * 1.5 / (math.pi * 72.0 * 0.8)
            return (max(5.0, min_size), max(5.0, min_size))

__init__(layout: TreeLayout, ax: Axes | None = None, *, tree_style: TreeStyle | None = None, rescale_depth: bool = True, show_labels: bool = True, show_internal_labels: bool = False, show_ghost_segments: bool = True, label_pad: float = 4.0, figsize: tuple[float, float] | None = None) -> None

Construct a renderer.

Parameters:

Name Type Description Default
layout TreeLayout

Pre-computed tree layout to render.

required
ax Axes | None

Existing :class:~matplotlib.axes.Axes to draw on. When None (the default) a new figure and axes are created automatically when :meth:render is called.

None
tree_style TreeStyle | None

Styling configuration for nodes, edges, and labels. When None the default :class:~tralda.visualization.style.TreeStyle is used.

None
rescale_depth bool

Normalise the depth axis to [0, 1] before rendering. Recommended when edge lengths are in absolute units (e.g. millions of years). Default True.

True
show_labels bool

Draw leaf labels. Default True.

True
show_internal_labels bool

Also draw labels for internal nodes (including the root). Only nodes that have a label attribute are labelled. Default False.

False
show_ghost_segments bool

Extend short leaves to the maximum depth with a dashed line. Only visible when leaves sit at different depths (ATTR / UNIFORM edge-length modes). Default True.

True
label_pad float

Gap between the symbol edge and the start of the label text, in points. Default 4.

4.0
figsize tuple[float, float] | None

Figure size (width, height) in inches. When None the size is derived automatically from the leaf count and layout mode.

None
Source code in tralda/visualization/matplotlib_renderer/_renderer.py
def __init__(
    self,
    layout: TreeLayout,
    ax: Axes | None = None,
    *,
    tree_style: TreeStyle | None = None,
    rescale_depth: bool = True,
    show_labels: bool = True,
    show_internal_labels: bool = False,
    show_ghost_segments: bool = True,
    label_pad: float = 4.0,
    figsize: tuple[float, float] | None = None,
) -> None:
    """Construct a renderer.

    Args:
        layout: Pre-computed tree layout to render.
        ax: Existing :class:`~matplotlib.axes.Axes` to draw on.  When ``None`` (the default)
            a new figure and axes are created automatically when :meth:`render` is called.
        tree_style: Styling configuration for nodes, edges, and labels.  When ``None`` the
            default :class:`~tralda.visualization.style.TreeStyle` is used.
        rescale_depth: Normalise the depth axis to ``[0, 1]`` before rendering.  Recommended
            when edge lengths are in absolute units (e.g. millions of years).  Default ``True``.
        show_labels: Draw leaf labels.  Default ``True``.
        show_internal_labels: Also draw labels for internal nodes (including the root).  Only
            nodes that have a ``label`` attribute are labelled.  Default ``False``.
        show_ghost_segments: Extend short leaves to the maximum depth with a dashed line.  Only
            visible when leaves sit at different depths (``ATTR`` / ``UNIFORM`` edge-length
            modes).  Default ``True``.
        label_pad: Gap between the symbol edge and the start of the label text, in points.
            Default ``4``.
        figsize: Figure size ``(width, height)`` in inches.  When ``None`` the size is derived
            automatically from the leaf count and layout mode.
    """
    super().__init__(
        layout,
        tree_style=tree_style,
        rescale_depth=rescale_depth,
        show_labels=show_labels,
        show_internal_labels=show_internal_labels,
        show_ghost_segments=show_ghost_segments,
    )
    self._user_ax = ax
    self.label_pad = label_pad
    self.figsize = figsize

render() -> tuple[Figure, Axes]

Draw the tree and return the figure and axes.

When no ax was supplied to the constructor a new figure is created with an automatically derived size. fig.tight_layout() is called automatically for new figures; when drawing on a caller-supplied axes the caller is responsible for layout management.

Returns:

Type Description
Figure

(fig, ax) — the :class:~matplotlib.figure.Figure and

Axes

class:~matplotlib.axes.Axes the tree was drawn on.

Source code in tralda/visualization/matplotlib_renderer/_renderer.py
def render(self) -> tuple[Figure, Axes]:
    """Draw the tree and return the figure and axes.

    When no *ax* was supplied to the constructor a new figure is created with an automatically
    derived size.  ``fig.tight_layout()`` is called automatically for new figures; when drawing
    on a caller-supplied axes the caller is responsible for layout management.

    Returns:
        ``(fig, ax)`` — the :class:`~matplotlib.figure.Figure` and
        :class:`~matplotlib.axes.Axes` the tree was drawn on.
    """
    new_fig = False
    if self._user_ax is not None:
        ax = self._user_ax
        fig = ax.get_figure()
    else:
        fig, ax = plt.subplots(figsize=self._auto_figsize())
        new_fig = True

    mode = self.layout.layout_mode
    positions = self._build_positions()

    # Resolve per-node styles once, reused by edge and node drawing passes.
    node_styles: dict[TreeNode, NodeStyle] = {
        v: self.tree_style.resolve(v, mode) for v in self.layout.tree.preorder()
    }

    self._draw_edges(ax, positions, node_styles)
    self._draw_nodes(ax, positions, node_styles, mode)
    if self.show_labels or self.show_internal_labels:
        self._draw_labels(ax, positions, node_styles)
    self._style_axes(ax)

    if new_fig:
        fig.tight_layout()

    return fig, ax

register_symbol(name: str, drawer: SymbolDrawer) -> None

Register a custom symbol drawer in the module-level registry.

Once registered, the symbol is immediately available to all :class:MatplotlibRenderer instances by name.

Parameters:

Name Type Description Default
name str

Key under which the drawer is registered.

required
drawer SymbolDrawer

Callable with the signature (ax, x, y, ns, *, angle, layout_mode, **kwargs) -> None, where ns is the fully-resolved :class:~tralda.visualization.style.NodeStyle for the node. ns.symbol_size is in points (like markersize). angle is the edge-direction angle in degrees (0 = right, CCW positive); drawers invariant under rotation may ignore it. layout_mode is the current :class:~tralda.visualization.layout.LayoutMode.

required
Source code in tralda/visualization/matplotlib_renderer/_symbols.py
def register_symbol(name: str, drawer: SymbolDrawer) -> None:
    """Register a custom symbol drawer in the module-level registry.

    Once registered, the symbol is immediately available to all :class:`MatplotlibRenderer`
    instances by name.

    Args:
        name: Key under which the drawer is registered.
        drawer: Callable with the signature
            ``(ax, x, y, ns, *, angle, layout_mode, **kwargs) -> None``, where *ns* is the
            fully-resolved :class:`~tralda.visualization.style.NodeStyle` for the node.
            *ns.symbol_size* is in points (like ``markersize``).  *angle* is the edge-direction
            angle in degrees (0 = right, CCW positive); drawers invariant under rotation may
            ignore it.
            *layout_mode* is the current :class:`~tralda.visualization.layout.LayoutMode`.
    """
    SYMBOL_REGISTRY[name] = drawer

Plotly backend

PlotlyRenderer

Bases: BaseRenderer

Render a :class:~tralda.visualization.layout.TreeLayout as an interactive Plotly figure.

All tree geometry is read from a pre-computed :class:~tralda.visualization.layout.TreeLayout. Node appearance is controlled by a :class:~tralda.visualization.style.TreeStyle.

Example usage::

from tralda.visualization.layout import TreeLayout
from tralda.visualization.plotly_renderer import PlotlyRenderer
from tralda.visualization.style import TreeStyle

layout = TreeLayout(tree, edge_length_mode="attr")
style = TreeStyle(leaf_symbol="circle")
renderer = PlotlyRenderer(layout, tree_style=style, hover_attrs=["dist", "support"])
fig = renderer.render()
fig.show()                      # interactive browser / Jupyter
fig.write_html("tree.html")     # self-contained HTML file
fig.write_image("tree.png")     # static image, requires the 'kaleido' package
                                # (included in the 'plotly' extra:
                                # pip install tralda[plotly])
Source code in tralda/visualization/plotly_renderer/_renderer.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
class PlotlyRenderer(BaseRenderer):
    """Render a :class:`~tralda.visualization.layout.TreeLayout` as an interactive Plotly figure.

    All tree geometry is read from a pre-computed :class:`~tralda.visualization.layout.TreeLayout`.
    Node appearance is controlled by a :class:`~tralda.visualization.style.TreeStyle`.

    Example usage::

        from tralda.visualization.layout import TreeLayout
        from tralda.visualization.plotly_renderer import PlotlyRenderer
        from tralda.visualization.style import TreeStyle

        layout = TreeLayout(tree, edge_length_mode="attr")
        style = TreeStyle(leaf_symbol="circle")
        renderer = PlotlyRenderer(layout, tree_style=style, hover_attrs=["dist", "support"])
        fig = renderer.render()
        fig.show()                      # interactive browser / Jupyter
        fig.write_html("tree.html")     # self-contained HTML file
        fig.write_image("tree.png")     # static image, requires the 'kaleido' package
                                        # (included in the 'plotly' extra:
                                        # pip install tralda[plotly])
    """

    def __init__(
        self,
        layout: TreeLayout,
        *,
        tree_style: TreeStyle | None = None,
        rescale_depth: bool = True,
        show_labels: bool = True,
        show_internal_labels: bool = False,
        show_ghost_segments: bool = True,
        width: int | None = None,
        height: int | None = None,
        hover_attrs: list[str] | str | None = None,
    ) -> None:
        """Construct a renderer.

        Args:
            layout: Pre-computed tree layout to render.
            tree_style: Styling configuration for nodes, edges, and labels. When ``None`` the
                default :class:`~tralda.visualization.style.TreeStyle` is used.
            rescale_depth: Normalise the depth axis to ``[0, 1]`` before rendering. Default
                ``True``.
            show_labels: Draw leaf labels. Default ``True``.
            show_internal_labels: Also draw labels for internal nodes (including the root). Only
                nodes that have a ``label`` attribute are labelled. Default ``False``.
            show_ghost_segments: Extend short leaves to the maximum depth with a dashed line. Only
                visible when leaves sit at different depths (``ATTR`` / ``UNIFORM`` edge-length
                modes). Default ``True``.
            width: Figure width in pixels. When ``None`` an automatic width is derived from the
                leaf count and layout mode.
            height: Figure height in pixels. When ``None`` an automatic height is derived from
                the leaf count and layout mode.
            hover_attrs: One or more node attribute names to include in hover tooltips in addition
                to ``label`` and ``depth``. Accepts a single string or a list of strings.
        """
        super().__init__(
            layout,
            tree_style=tree_style,
            rescale_depth=rescale_depth,
            show_labels=show_labels,
            show_internal_labels=show_internal_labels,
            show_ghost_segments=show_ghost_segments,
        )
        self.width = width
        self.height = height
        self.hover_attrs: list[str] = (
            [hover_attrs] if isinstance(hover_attrs, str) else (hover_attrs or [])
        )

    # ----------------------------------------------------------------------------------------------
    # Public API
    # ----------------------------------------------------------------------------------------------

    def render(self) -> go.Figure:
        """Build and return the Plotly figure.

        Returns:
            A :class:`plotly.graph_objects.Figure` containing all tree traces.
        """
        mode = self.layout.layout_mode
        positions = self._build_positions()

        node_styles: dict[TreeNode, NodeStyle] = {
            v: self.tree_style.resolve(v, mode) for v in self.layout.tree.preorder()
        }

        traces: list[Any] = []
        traces.extend(self._edge_traces(positions, node_styles))
        if self.show_ghost_segments:
            ghost = self._ghost_trace()
            if ghost is not None:
                traces.append(ghost)
        traces.extend(get_node_traces(self.layout, positions, node_styles, self.hover_attrs))
        fig = go.Figure(data=traces)
        self._configure_layout(fig)
        self._add_label_annotations(fig, positions, node_styles)
        return fig

    # ----------------------------------------------------------------------------------------------
    # Edge traces
    # ----------------------------------------------------------------------------------------------

    def _edge_traces(
        self,
        positions: dict[TreeNode, tuple[float, float]],
        node_styles: dict[TreeNode, NodeStyle],
    ) -> list[go.Scatter]:
        """Build batched edge traces, one per unique (color, width, dash) combination.

        Edges that share a style are grouped into a single :class:`~plotly.graph_objects.Scatter`
        trace using ``None`` gaps to break the line between separate segments.  Circular arcs are
        added as continuous polylines (no intra-arc None gaps).
        """
        layout = self.layout
        mode = layout.layout_mode
        ts = self.tree_style

        # Accumulator: (color, lw, dash) → (x_list, y_list) with None gaps between segments.
        groups: dict[EdgeKey, tuple[list[float | None], list[float | None]]] = {}

        def _key(ns: NodeStyle) -> EdgeKey:
            return (
                to_plotly_color(ns.edge_color),
                float(ns.edge_lw),
                resolve_linestyle(str(ns.edge_ls)),
            )

        def _add_segment(k: EdgeKey, x0: float, y0: float, x1: float, y1: float) -> None:
            if k not in groups:
                groups[k] = ([], [])
            xs, ys = groups[k]
            xs += [x0, x1, None]
            ys += [y0, y1, None]

        def _add_polyline(k: EdgeKey, pts: list[tuple[float, float]]) -> None:
            """Append a connected polyline (no intra-segment None gaps)."""
            if not pts:
                return
            if k not in groups:
                groups[k] = ([], [])
            xs, ys = groups[k]
            for px, py in pts:
                xs.append(px)
                ys.append(py)
            xs.append(None)
            ys.append(None)

        for v in layout.tree.preorder():
            vx, vy = positions[v]
            ns = node_styles[v]
            k = _key(ns)

            # ── parent edge ───────────────────────────────────────────────────────────────────
            if v.parent is not None:
                px, py = positions[v.parent]
                x0, y0, x1, y1 = self._parent_edge_segment(vx, vy, px, py, mode)
                _add_segment(k, x0, y0, x1, y1)

            # ── child connector ───────────────────────────────────────────────────────────────
            if v.children:
                children, first_pos, last_pos = self._child_connector_range(v, positions)
                cs = ts.consensus_style(children, mode)
                ck = _key(cs)

                if mode is LayoutMode.HORIZONTAL:
                    _add_segment(ck, vx, first_pos[1], vx, last_pos[1])
                elif mode is LayoutMode.VERTICAL:
                    _add_segment(ck, first_pos[0], vy, last_pos[0], vy)
                else:  # CIRCULAR — arc at the node's radius
                    arc_pts = _arc_polyline(vx, vy, first_pos, last_pos)
                    _add_polyline(ck, arc_pts)

        traces = []
        for (color, lw, dash), (xs, ys) in groups.items():
            traces.append(
                go.Scatter(
                    x=xs,
                    y=ys,
                    mode="lines",
                    line=dict(color=color, width=lw, dash=dash),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )
        return traces

    # ----------------------------------------------------------------------------------------------
    # Ghost trace
    # ----------------------------------------------------------------------------------------------

    def _ghost_trace(self) -> go.Scatter | None:
        """Build a single batched ghost-segment trace for all leaves."""
        layout = self.layout
        mode = layout.layout_mode
        ts = self.tree_style

        xs: list[float | None] = []
        ys: list[float | None] = []

        for v in layout.tree.leaves():
            seg = layout.ghost_segment(v)
            if seg is None:
                continue
            (x0, y0), (x1, y1) = seg
            x0, y0, x1, y1 = self._rescale_seg(x0, y0, x1, y1, mode)
            xs += [x0, x1, None]
            ys += [y0, y1, None]

        if not xs:
            return None

        return go.Scatter(
            x=xs,
            y=ys,
            mode="lines",
            line=dict(
                color=to_plotly_color(ts.ghost_color),
                width=ts.ghost_lw,
                dash=resolve_linestyle(ts.ghost_ls),
            ),
            hoverinfo="skip",
            showlegend=False,
        )

    # ----------------------------------------------------------------------------------------------
    # Label trace
    # ----------------------------------------------------------------------------------------------

    def _add_label_annotations(
        self,
        fig: go.Figure,
        positions: dict[TreeNode, tuple[float, float]],
        node_styles: dict[TreeNode, NodeStyle],
    ) -> None:
        """Add label annotations to *fig* for all layout modes.

        :func:`plotly.graph_objects.Figure.add_annotation` supports per-label ``textangle`` and
        pixel-space ``xshift``/``yshift``, which allows correct padding and rotation for every
        layout mode:

        * **Horizontal** — labels placed to the right of each node with a radial pixel gap;
          text is horizontal (``textangle = 0``).
        * **Vertical** — labels placed below each node (in screen space) with a pixel gap;
          text is rotated 90° clockwise so that labels read top-to-bottom and do not overlap.
        * **Circular** — each label is offset radially outward using the node's polar angle;
          text on the left half of the tree is flipped by 180° so it always reads outward from
          the root.  The radial offset uses the *original* (pre-flip) angle so that the offset
          vector points outward regardless of the flip.
        """
        layout = self.layout
        mode = layout.layout_mode
        _PAD = 4.0  # gap between symbol edge and label start, in pixels

        # Accumulator for all annotations; added in a single batch at the end for performance.
        annotations: list[go.layout.Annotation] = []

        for v in layout.tree.preorder():
            if v.is_leaf() and not self.show_labels:
                continue
            if not v.is_leaf() and not self.show_internal_labels:
                continue

            label = getattr(v, "label", None)
            if label is None:
                continue

            ns = node_styles[v]
            x, y = positions[v]
            x, y = self._ghost_label_anchor(v, x, y, mode)

            offset = ns.symbol_size / 2.0 + _PAD
            angle = layout.label_angle.get(v, 0.0)
            ha = layout.label_ha.get(v, "left")
            font_size = to_plotly_font_size(ns.label_fontsize)

            if mode is LayoutMode.HORIZONTAL:
                # Leaves are at the rightmost position; push label further right.
                xshift, yshift = offset, 0.0
                textangle = 0.0
                xanchor, yanchor = "left", "middle"
            elif mode is LayoutMode.VERTICAL:
                # Leaves are at the screen bottom (y-axis reversed); push label downward.
                # Rotate 90° CW so labels read top-to-bottom and do not overlap.
                xshift, yshift = 0.0, -offset
                textangle = 90.0
                xanchor, yanchor = "center", "top"
            else:  # CIRCULAR
                # Compute the offset vector from the ORIGINAL angle so it always points outward,
                # regardless of the 180° readability flip applied to the text rotation.
                display_angle = angle
                if not (-90.0 <= angle <= 90.0):
                    display_angle = angle - 180.0 if angle >= 0.0 else angle + 180.0
                # Plotly textangle is clockwise-from-horizontal; matplotlib uses CCW.
                textangle = -display_angle

                # Determine the anchor and shift direction based on the original angle.  Plotly uses
                # a different convention for the relationship between textangle and x/yanchor than
                # matplotlib. The following logic produces a similar visual result to matplotlib.
                # Using yanchor="bottom"/"top" shifts the anchor by half the line-box height.
                # Plotly uses a CSS line-height of 1.5, so the line box is 1.5 * fontsize tall and
                # half of that is 0.75 * fontsize. The correction nudges the anchor back to the text
                # centre along the text-perpendicular direction.
                xanchor = "left" if ha == "left" else "right"
                yanchor = "bottom" if 0.0 <= angle <= 180.0 else "top"
                orig_rad = math.radians(angle)
                xshift = math.cos(orig_rad) * offset
                yshift = math.sin(orig_rad) * offset
                correction_factor = font_size * 0.75
                if 0.0 <= angle <= 90.0 or -180.0 <= angle < -90.0:
                    xshift -= correction_factor * math.sin(orig_rad)
                    yshift -= correction_factor * math.cos(orig_rad)
                else:  # -90.0 <= angle < 0.0 or 90.0 < angle <= 180.0
                    xshift += correction_factor * math.sin(orig_rad)
                    yshift += correction_factor * math.cos(orig_rad)

            # Apply font weight and style via HTML tags.
            text = html.escape(str(label))
            if ns.label_fontstyle == "italic":
                text = f"<i>{text}</i>"
            if ns.label_fontweight == "bold":
                text = f"<b>{text}</b>"

            annotations.append(
                go.layout.Annotation(
                    x=x,
                    y=y,
                    text=text,
                    textangle=textangle,
                    showarrow=False,
                    font=dict(
                        color=to_plotly_color(ns.label_color),
                        size=font_size,
                    ),
                    xanchor=xanchor,
                    yanchor=yanchor,
                    xshift=xshift,
                    yshift=yshift,
                    xref="x",
                    yref="y",
                )
            )

        if annotations:
            fig.update_layout(annotations=annotations)

    # ----------------------------------------------------------------------------------------------
    # Figure layout configuration
    # ----------------------------------------------------------------------------------------------

    def _configure_layout(self, fig: go.Figure) -> None:
        """Remove decorations and orient axes to match the chosen layout mode.

        Args:
            fig: The figure to configure.
        """
        mode = self.layout.layout_mode
        n = self.layout.rank_span
        w, h = self._auto_size()

        clean_axis: dict[str, Any] = dict(
            showticklabels=False,
            showgrid=False,
            zeroline=False,
            showline=False,
            ticks="",
        )

        if mode is LayoutMode.HORIZONTAL:
            fig.update_layout(
                width=w,
                height=h,
                xaxis=dict(**clean_axis),
                yaxis=dict(**clean_axis, range=[n - 0.5, -0.5]),
                plot_bgcolor="white",
                margin=dict(l=10, r=10, t=10, b=10),
            )
        elif mode is LayoutMode.VERTICAL:
            fig.update_layout(
                width=w,
                height=h,
                xaxis=dict(**clean_axis, range=[-0.5, n - 0.5]),
                yaxis=dict(**clean_axis, autorange="reversed"),
                plot_bgcolor="white",
                margin=dict(l=10, r=10, t=10, b=10),
            )
        else:  # CIRCULAR — equal aspect ratio so the tree is not distorted
            fig.update_layout(
                width=w,
                height=h,
                xaxis=dict(**clean_axis, scaleanchor="y", scaleratio=1),
                yaxis=dict(**clean_axis),
                plot_bgcolor="white",
                margin=dict(l=10, r=10, t=10, b=10),
            )

    def _auto_size(self) -> tuple[int, int]:
        """Return a sensible default figure size in pixels from the leaf count and symbol size."""
        n = max(1, self.layout.rank_span)
        mode = self.layout.layout_mode
        symbol_size = self.tree_style.default.symbol_size

        if mode is LayoutMode.HORIZONTAL:
            return self.width or 1000, self.height or max(300, int(n * symbol_size * 1.7))
        elif mode is LayoutMode.VERTICAL:
            return self.width or max(300, int(n * symbol_size * 1.7)), self.height or 800
        else:  # CIRCULAR
            # Scale so adjacent leaf symbols have adequate arc spacing.
            # Effective circumference ≈ π × figure_px; setting arc-per-leaf ≥ 2 × symbol_size
            # and solving: figure_px ≥ n × symbol_size × 2 / π.
            px = max(500, int(n * symbol_size * 2 / math.pi))
            return self.width or px, self.height or px

__init__(layout: TreeLayout, *, tree_style: TreeStyle | None = None, rescale_depth: bool = True, show_labels: bool = True, show_internal_labels: bool = False, show_ghost_segments: bool = True, width: int | None = None, height: int | None = None, hover_attrs: list[str] | str | None = None) -> None

Construct a renderer.

Parameters:

Name Type Description Default
layout TreeLayout

Pre-computed tree layout to render.

required
tree_style TreeStyle | None

Styling configuration for nodes, edges, and labels. When None the default :class:~tralda.visualization.style.TreeStyle is used.

None
rescale_depth bool

Normalise the depth axis to [0, 1] before rendering. Default True.

True
show_labels bool

Draw leaf labels. Default True.

True
show_internal_labels bool

Also draw labels for internal nodes (including the root). Only nodes that have a label attribute are labelled. Default False.

False
show_ghost_segments bool

Extend short leaves to the maximum depth with a dashed line. Only visible when leaves sit at different depths (ATTR / UNIFORM edge-length modes). Default True.

True
width int | None

Figure width in pixels. When None an automatic width is derived from the leaf count and layout mode.

None
height int | None

Figure height in pixels. When None an automatic height is derived from the leaf count and layout mode.

None
hover_attrs list[str] | str | None

One or more node attribute names to include in hover tooltips in addition to label and depth. Accepts a single string or a list of strings.

None
Source code in tralda/visualization/plotly_renderer/_renderer.py
def __init__(
    self,
    layout: TreeLayout,
    *,
    tree_style: TreeStyle | None = None,
    rescale_depth: bool = True,
    show_labels: bool = True,
    show_internal_labels: bool = False,
    show_ghost_segments: bool = True,
    width: int | None = None,
    height: int | None = None,
    hover_attrs: list[str] | str | None = None,
) -> None:
    """Construct a renderer.

    Args:
        layout: Pre-computed tree layout to render.
        tree_style: Styling configuration for nodes, edges, and labels. When ``None`` the
            default :class:`~tralda.visualization.style.TreeStyle` is used.
        rescale_depth: Normalise the depth axis to ``[0, 1]`` before rendering. Default
            ``True``.
        show_labels: Draw leaf labels. Default ``True``.
        show_internal_labels: Also draw labels for internal nodes (including the root). Only
            nodes that have a ``label`` attribute are labelled. Default ``False``.
        show_ghost_segments: Extend short leaves to the maximum depth with a dashed line. Only
            visible when leaves sit at different depths (``ATTR`` / ``UNIFORM`` edge-length
            modes). Default ``True``.
        width: Figure width in pixels. When ``None`` an automatic width is derived from the
            leaf count and layout mode.
        height: Figure height in pixels. When ``None`` an automatic height is derived from
            the leaf count and layout mode.
        hover_attrs: One or more node attribute names to include in hover tooltips in addition
            to ``label`` and ``depth``. Accepts a single string or a list of strings.
    """
    super().__init__(
        layout,
        tree_style=tree_style,
        rescale_depth=rescale_depth,
        show_labels=show_labels,
        show_internal_labels=show_internal_labels,
        show_ghost_segments=show_ghost_segments,
    )
    self.width = width
    self.height = height
    self.hover_attrs: list[str] = (
        [hover_attrs] if isinstance(hover_attrs, str) else (hover_attrs or [])
    )

render() -> go.Figure

Build and return the Plotly figure.

Returns:

Name Type Description
A Figure

class:plotly.graph_objects.Figure containing all tree traces.

Source code in tralda/visualization/plotly_renderer/_renderer.py
def render(self) -> go.Figure:
    """Build and return the Plotly figure.

    Returns:
        A :class:`plotly.graph_objects.Figure` containing all tree traces.
    """
    mode = self.layout.layout_mode
    positions = self._build_positions()

    node_styles: dict[TreeNode, NodeStyle] = {
        v: self.tree_style.resolve(v, mode) for v in self.layout.tree.preorder()
    }

    traces: list[Any] = []
    traces.extend(self._edge_traces(positions, node_styles))
    if self.show_ghost_segments:
        ghost = self._ghost_trace()
        if ghost is not None:
            traces.append(ghost)
    traces.extend(get_node_traces(self.layout, positions, node_styles, self.hover_attrs))
    fig = go.Figure(data=traces)
    self._configure_layout(fig)
    self._add_label_annotations(fig, positions, node_styles)
    return fig