"""
Terminal preview: text drawings of phylogenetic networks.
A network is drawn root-left / leaves-right on a character grid, using the
rectangular ``pz-cladogram`` placement: tree edges as box-drawing lines, hybrid
edges as dotted lines with an arrowhead at the hybrid node. Semi-directed
networks are rooted first. Needs no matplotlib.
Legend::
○ root ● tree node / leaf ◆ hybrid node
─ │ ┌ └ ├ … tree edges ┄ ┊ hybrid edges, ending in > < v ^
"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any
from phylozoo.utils.exceptions import (
PhyloZooEmptyNetworkWarning,
PhyloZooSingleNodeNetworkWarning,
PhyloZooTypeError,
PhyloZooValueError,
)
if TYPE_CHECKING:
from phylozoo.core.network.dnetwork import DirectedPhyNetwork
from phylozoo.core.network.sdnetwork import SemiDirectedPhyNetwork
_BOX = {
frozenset("EW"): "─",
frozenset("NS"): "│",
frozenset("SE"): "┌",
frozenset("SW"): "┐",
frozenset("NE"): "└",
frozenset("NW"): "┘",
frozenset("NSE"): "├",
frozenset("NSW"): "┤",
frozenset("SEW"): "┬",
frozenset("NEW"): "┴",
frozenset("NSEW"): "┼",
frozenset("E"): "─",
frozenset("W"): "─",
frozenset("N"): "│",
frozenset("S"): "│",
}
_DOTTED_H, _DOTTED_V, _CROSS = "┄", "┊", "┼"
_ROOT, _NODE, _HYBRID, _LEAF = "○", "●", "◆", "●"
_HEADS = {"E": ">", "W": "<", "S": "v", "N": "^"}
[docs]
def to_preview_string(
network: "DirectedPhyNetwork | SemiDirectedPhyNetwork",
col_width: int | None = None,
rows_per_leaf: int | None = None,
max_width: int = 120,
max_leaves: int = 80,
root_location: Any = None,
**layout_kwargs: Any,
) -> str:
"""
Draw a network as text, root on the left and leaves on the right.
Tree edges are box-drawing lines (``─ │ ┌ └ ├`` ...), hybrid edges dotted
lines (``┄ ┊``) ending in an arrowhead at the hybrid node (``◆``); the
root is ``○``, other nodes ``●``, and leaf labels follow their leaf. The
placement is that of the rectangular ``pz-cladogram`` layout, so hybrid edges
are horizontal or vertical where the network allows it and sibling
subtrees stay together. A semi-directed network is rooted first with
:func:`~phylozoo.core.network.sdnetwork.derivations.to_d_network`.
Parallel edges are drawn as a loop: the second copy detours one row above
(or below) the first, so both stay visible.
Large networks are compacted automatically: the column width shrinks
until the drawing fits ``max_width`` characters (down to 3) and the blank
row between leaves is dropped above 25 leaves. Networks with more than
``max_leaves`` leaves are refused, because one text row per leaf stops
being readable; raise ``max_leaves`` or use :func:`~phylozoo.viz.plot`.
Parameters
----------
network : DirectedPhyNetwork | SemiDirectedPhyNetwork
The network to draw.
col_width : int | None, optional
Characters per layer. None picks the largest of 8, 6, 4, 3 that fits
``max_width``. By default None.
rows_per_leaf : int | None, optional
Rows per leaf: 2 leaves a blank row between leaves, 1 is compact.
None chooses 2 up to 25 leaves and 1 above. By default None.
max_width : int, optional
Target maximum line length used to pick ``col_width``. By default 120.
max_leaves : int, optional
Refuse networks with more leaves than this. By default 80.
root_location : node or edge, optional
Semi-directed networks only: where to root (see ``to_d_network``).
None lets ``to_d_network`` choose. By default None.
**layout_kwargs
Options of :func:`~phylozoo.viz.dnetwork.layout.cladogram.compute_pz_cladogram_layout`
(e.g. ``trials``, ``seed``, ``horizontal_reticulations``).
Returns
-------
str
The drawing, lines separated by newlines, without trailing spaces.
Raises
------
PhyloZooTypeError
If ``network`` is not a directed or semi-directed network.
PhyloZooValueError
If the network has more than ``max_leaves`` leaves.
Examples
--------
>>> from phylozoo import DirectedPhyNetwork
>>> from phylozoo.viz import to_preview_string
>>> net = DirectedPhyNetwork(
... edges=[(5, 3), (5, 4), (3, 2), (4, 2), (2, 1), (3, 6), (4, 7)],
... nodes=[(1, {'label': 'A'}), (6, {'label': 'B'}), (7, {'label': 'C'})]
... )
>>> print(to_preview_string(net))
┌───────────────● B
┌───────●
○ └┄┄┄┄┄┄>◆───────● A
│ ^
└───────────────●───────● C
"""
from phylozoo.core.network.dnetwork import DirectedPhyNetwork
from phylozoo.core.network.sdnetwork import SemiDirectedPhyNetwork
if isinstance(network, DirectedPhyNetwork):
rooted = network
elif isinstance(network, SemiDirectedPhyNetwork):
from phylozoo.core.network.sdnetwork.derivations import to_d_network
if network.number_of_nodes() <= 1:
return _tiny(network)
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=PhyloZooEmptyNetworkWarning)
warnings.filterwarnings("ignore", category=PhyloZooSingleNodeNetworkWarning)
rooted = to_d_network(network, root_location=root_location)
else:
raise PhyloZooTypeError(
f"to_preview_string expects a DirectedPhyNetwork or SemiDirectedPhyNetwork, got {type(network).__name__}"
)
if rooted.number_of_nodes() <= 1:
return _tiny(rooted)
n_leaves = len(rooted.leaves)
if n_leaves > max_leaves:
raise PhyloZooValueError(
f"Network has {n_leaves} leaves, more than max_leaves={max_leaves}; a text drawing "
"needs one row per leaf. Raise max_leaves or use phylozoo.viz.plot()."
)
return _draw(rooted, col_width, rows_per_leaf, max_width, layout_kwargs)
def _tiny(network: Any) -> str:
"""Drawing of an empty or single-node network."""
nodes = list(network._graph.nodes)
if not nodes:
return ""
return f"{_LEAF} {network.get_label(nodes[0]) or nodes[0]}"
def _draw(
net: "DirectedPhyNetwork",
col_width: int | None,
rows_per_leaf: int | None,
max_width: int,
layout_kwargs: dict[str, Any],
) -> str:
from phylozoo.viz.dnetwork.layout.cladogram import compute_pz_cladogram_layout
layout_kwargs.setdefault("rectangular", True)
layout = compute_pz_cladogram_layout(net, direction="LR", **layout_kwargs)
pos = layout.positions
layers = sorted({round(x, 9) for x, _ in pos.values()})
leaves = sorted((n for n in pos if n in net.leaves), key=lambda n: -pos[n][1])
labels = [str(net.get_label(n) or n) for n in leaves]
if rows_per_leaf is None:
rows_per_leaf = 2 if len(leaves) <= 25 else 1
if col_width is None:
longest = max(len(s) for s in labels) + 2
col_width = next(
(w for w in (8, 6, 4, 3) if (len(layers) - 1) * w + 1 + longest <= max_width), 3
)
top = pos[leaves[0]][1]
step = (top - pos[leaves[-1]][1]) / max(len(leaves) - 1, 1) or 1.0
col_of = {x: i * col_width for i, x in enumerate(layers)}
def cell(point: tuple[float, float]) -> tuple[int, int]:
x, y = point
nearest = min(layers, key=lambda lx: abs(lx - x))
return col_of[nearest], round((top - y) / step * rows_per_leaf)
n_rows = max(cell(p)[1] for p in pos.values()) + 1
n_cols = max(cell(p)[0] for p in pos.values()) + 1
tree: list[list[set[str]]] = [[set() for _ in range(n_cols)] for _ in range(n_rows)]
dotted: list[list[set[str]]] = [[set() for _ in range(n_cols)] for _ in range(n_rows)]
def segment(grid: list[list[set[str]]], a: tuple[int, int], b: tuple[int, int]) -> None:
(c0, r0), (c1, r1) = a, b
if r0 == r1:
for c in range(min(c0, c1), max(c0, c1) + 1):
if c > min(c0, c1):
grid[r0][c].add("W")
if c < max(c0, c1):
grid[r0][c].add("E")
else:
for r in range(min(r0, r1), max(r0, r1) + 1):
if r > min(r0, r1):
grid[r][c0].add("N")
if r < max(r0, r1):
grid[r][c0].add("S")
heads: list[tuple[tuple[int, int], tuple[int, int]]] = []
copies: dict[tuple[Any, Any], list[int]] = {}
for u, v, key in layout.edge_routes:
copies.setdefault((u, v), []).append(key)
for (u, v, key), route in layout.edge_routes.items():
cells = [cell(p) for p in route.points]
cells = [c for i, c in enumerate(cells) if i == 0 or c != cells[i - 1]]
nth = sorted(copies[(u, v)]).index(key)
if nth: # extra parallel copy: detour one row per copy so it stays visible
shift = -nth if cells[0][1] - nth >= 0 else nth
cells = (
[cells[0], (cells[0][0], cells[0][1] + shift)]
+ [(c, r + shift) for c, r in cells[1:-1]]
+ [(cells[-1][0], cells[-1][1] + shift), cells[-1]]
)
cells = [c for i, c in enumerate(cells) if i == 0 or c != cells[i - 1]]
grid = dotted if route.edge_type.is_hybrid else tree
for a, b in zip(cells, cells[1:]):
segment(grid, a, b)
if route.edge_type.is_hybrid and len(cells) > 1:
heads.append((cells[-1], cells[-2]))
out = [[" "] * n_cols for _ in range(n_rows)]
for r in range(n_rows):
for c in range(n_cols):
solid, dots = tree[r][c], dotted[r][c]
if solid and dots:
# Same orientation: the hybrid edge runs along the tree edge, show it dotted;
# different orientation: a real crossing.
parallel = bool(solid & {"N", "S"}) == bool(dots & {"N", "S"})
out[r][c] = (_DOTTED_V if dots & {"N", "S"} else _DOTTED_H) if parallel else _CROSS
elif solid:
out[r][c] = _BOX[frozenset(solid)]
elif dots & {"N", "S"} and dots & {"E", "W"}:
out[r][c] = _BOX[frozenset(dots)] # corner of a dotted detour
elif dots:
out[r][c] = _DOTTED_V if dots & {"N", "S"} else _DOTTED_H
for (c, r), (pc, pr) in heads: # arrowhead in the cell before the hybrid node
if pr == r:
direction = "E" if pc < c else "W"
ac, ar = (c - 1, r) if pc < c else (c + 1, r)
else:
direction = "S" if pr < r else "N"
ac, ar = (c, r - 1) if pr < r else (c, r + 1)
if 0 <= ar < n_rows and 0 <= ac < n_cols and not tree[ar][ac]:
out[ar][ac] = _HEADS[direction] # never over a tree edge
for node, point in pos.items():
c, r = cell(point)
if node in net.leaves:
out[r][c] = _LEAF
elif node in net.hybrid_nodes:
out[r][c] = _HYBRID
elif node == net.root_node:
out[r][c] = _ROOT
else:
out[r][c] = _NODE
for leaf, label in zip(leaves, labels):
out[cell(pos[leaf])[1]] += list(" " + label)
return "\n".join("".join(row).rstrip() for row in out)