phykit 2.1.51__tar.gz → 2.1.52__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {phykit-2.1.51 → phykit-2.1.52}/PKG-INFO +1 -1
- phykit-2.1.52/phykit/helpers/circular_layout.py +315 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/plot_config.py +7 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/phykit.py +141 -29
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ancestral_reconstruction.py +256 -126
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/character_map.py +221 -117
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/concordance_asr.py +108 -55
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/cont_map.py +120 -53
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/cophylo.py +84 -0
- phykit-2.1.52/phykit/services/tree/density_map.py +486 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/discordance_asymmetry.py +180 -74
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylo_heatmap.py +139 -0
- phykit-2.1.52/phykit/services/tree/quartet_pie.py +410 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rate_heterogeneity.py +123 -47
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/stochastic_character_map.py +160 -60
- phykit-2.1.52/phykit/version.py +1 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/PKG-INFO +1 -1
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/SOURCES.txt +1 -0
- phykit-2.1.51/phykit/services/tree/density_map.py +0 -362
- phykit-2.1.51/phykit/services/tree/quartet_pie.py +0 -310
- phykit-2.1.51/phykit/version.py +0 -1
- {phykit-2.1.51 → phykit-2.1.52}/LICENSE.md +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/README.md +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/__init__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/__main__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/cli_registry.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/errors.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/__init__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/boolean_argument_parsing.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/caching.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/discrete_models.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/files.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/json_output.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/parallel.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/parsimony_utils.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/quartet_utils.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/stats_summary.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/streaming.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/service_factories.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/__init__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/__init__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_entropy.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_length.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_recoding.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/base.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/column_score.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/composition_per_taxon.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/dna_threader.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/faidx.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/gc_content.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/mask_alignment.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/pairwise_identity.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/plot_alignment_qc.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rcv.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rcvt.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rename_fasta_entries.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/variable_sites.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/base.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/__init__.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/base.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/bipartition_support_stats.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/branch_length_multiplier.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/collapse_branches.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/consensus_network.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/consensus_tree.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/dvmc.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/evo_tempo_map.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/evolutionary_rate.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/fit_continuous.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/fit_discrete.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/hidden_paralogy_check.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/independent_contrasts.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/internal_branch_stats.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/internode_labeler.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/kf_distance.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/lb_score.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ltt.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/monophyly_check.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/network_signal.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ou_shift_detection.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ouwie.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/parsimony_score.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/patristic_distances.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phenogram.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_glm.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_ordination.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_regression.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_signal.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylomorphospace.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/polytomy_test.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/print_tree.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/prune_tree.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/quartet_network.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/relative_rate_test.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rename_tree_tips.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rf_distance.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/root_tree.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/saturation.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/spectral_discordance.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/spurious_sequence.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/terminal_branch_stats.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/threshold_model.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_labels.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_to_tip_distance.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/total_tree_length.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/treeness.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/treeness_over_rcv.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/vcv_utils.py +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/dependency_links.txt +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/entry_points.txt +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/requires.txt +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/top_level.txt +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/setup.cfg +0 -0
- {phykit-2.1.51 → phykit-2.1.52}/setup.py +0 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Shared circular (radial/fan) phylogram layout utilities.
|
|
2
|
+
|
|
3
|
+
Functions for computing polar coordinates from a phylogenetic tree and
|
|
4
|
+
drawing branches, arcs, tip labels, gradients, and multi-segment branches
|
|
5
|
+
on a circular layout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from typing import Any, Dict, List, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Coordinate computation
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def compute_circular_coords(tree, node_x, parent_map):
|
|
19
|
+
"""Compute Cartesian coordinates for a circular phylogram layout.
|
|
20
|
+
|
|
21
|
+
Each tip is evenly spaced around the circle. Internal node angles are
|
|
22
|
+
the midpoint of the angular range of their descendant tips (NOT the
|
|
23
|
+
circular mean, which fails when descendants span the full circle).
|
|
24
|
+
The root gets angle = 0 (irrelevant since its radius is 0).
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
tree : Bio.Phylo tree
|
|
29
|
+
The phylogenetic tree.
|
|
30
|
+
node_x : dict
|
|
31
|
+
Mapping ``id(clade) -> float`` giving the radial distance (reuses
|
|
32
|
+
existing phylogram / cladogram x-values).
|
|
33
|
+
parent_map : dict
|
|
34
|
+
Mapping ``id(child) -> parent_clade``.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
dict
|
|
39
|
+
``id(clade) -> {"x": float, "y": float, "angle": float, "radius": float}``
|
|
40
|
+
"""
|
|
41
|
+
tips = tree.get_terminals()
|
|
42
|
+
n_tips = len(tips)
|
|
43
|
+
|
|
44
|
+
# Assign each tip an evenly-spaced angle.
|
|
45
|
+
tip_angles: Dict[int, float] = {}
|
|
46
|
+
for i, tip in enumerate(tips):
|
|
47
|
+
tip_angles[id(tip)] = 2.0 * math.pi * i / n_tips
|
|
48
|
+
|
|
49
|
+
# We also need to know which tip indices belong to each internal node
|
|
50
|
+
# so that we can compute the angular *range* of its descendants.
|
|
51
|
+
# Walk postorder; for each node store (min_index, max_index) among tips.
|
|
52
|
+
tip_index = {id(t): i for i, t in enumerate(tips)}
|
|
53
|
+
node_tip_range: Dict[int, Tuple[int, int]] = {}
|
|
54
|
+
|
|
55
|
+
for clade in tree.find_clades(order="postorder"):
|
|
56
|
+
cid = id(clade)
|
|
57
|
+
if clade.is_terminal():
|
|
58
|
+
idx = tip_index[cid]
|
|
59
|
+
node_tip_range[cid] = (idx, idx)
|
|
60
|
+
else:
|
|
61
|
+
child_mins = []
|
|
62
|
+
child_maxs = []
|
|
63
|
+
for child in clade.clades:
|
|
64
|
+
cmin, cmax = node_tip_range[id(child)]
|
|
65
|
+
child_mins.append(cmin)
|
|
66
|
+
child_maxs.append(cmax)
|
|
67
|
+
node_tip_range[cid] = (min(child_mins), max(child_maxs))
|
|
68
|
+
|
|
69
|
+
# Build angle dict for every node.
|
|
70
|
+
node_angle: Dict[int, float] = {}
|
|
71
|
+
root = tree.root
|
|
72
|
+
for clade in tree.find_clades(order="postorder"):
|
|
73
|
+
cid = id(clade)
|
|
74
|
+
if clade.is_terminal():
|
|
75
|
+
node_angle[cid] = tip_angles[cid]
|
|
76
|
+
elif clade == root:
|
|
77
|
+
node_angle[cid] = 0.0
|
|
78
|
+
else:
|
|
79
|
+
mn, mx = node_tip_range[cid]
|
|
80
|
+
a_min = 2.0 * math.pi * mn / n_tips
|
|
81
|
+
a_max = 2.0 * math.pi * mx / n_tips
|
|
82
|
+
node_angle[cid] = (a_min + a_max) / 2.0
|
|
83
|
+
|
|
84
|
+
# Convert to Cartesian.
|
|
85
|
+
coords: Dict[int, Dict[str, float]] = {}
|
|
86
|
+
for clade in tree.find_clades(order="preorder"):
|
|
87
|
+
cid = id(clade)
|
|
88
|
+
radius = node_x[cid]
|
|
89
|
+
angle = node_angle[cid]
|
|
90
|
+
coords[cid] = {
|
|
91
|
+
"x": radius * math.cos(angle),
|
|
92
|
+
"y": radius * math.sin(angle),
|
|
93
|
+
"angle": angle,
|
|
94
|
+
"radius": radius,
|
|
95
|
+
}
|
|
96
|
+
return coords
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Arc helper (internal)
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _draw_arc(ax, cx, cy, radius, start_angle, end_angle, color, lw):
|
|
104
|
+
"""Draw a circular arc as a polyline.
|
|
105
|
+
|
|
106
|
+
Sweeps from *start_angle* to *end_angle* at the given *radius* around
|
|
107
|
+
the centre (*cx*, *cy*). Handles wrap-around so the arc always takes
|
|
108
|
+
the shorter path unless the caller has already adjusted the angles.
|
|
109
|
+
"""
|
|
110
|
+
# Normalise into [0, 2pi)
|
|
111
|
+
start = start_angle % (2.0 * math.pi)
|
|
112
|
+
end = end_angle % (2.0 * math.pi)
|
|
113
|
+
|
|
114
|
+
# Choose the shorter sweep direction.
|
|
115
|
+
diff = (end - start) % (2.0 * math.pi)
|
|
116
|
+
if diff > math.pi:
|
|
117
|
+
# Sweep the other way.
|
|
118
|
+
diff = diff - 2.0 * math.pi
|
|
119
|
+
|
|
120
|
+
n_pts = 61
|
|
121
|
+
angles = [start + diff * t / (n_pts - 1) for t in range(n_pts)]
|
|
122
|
+
xs = [cx + radius * math.cos(a) for a in angles]
|
|
123
|
+
ys = [cy + radius * math.sin(a) for a in angles]
|
|
124
|
+
ax.plot(xs, ys, color=color, linewidth=lw, solid_capstyle="round")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Drawing functions
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def draw_circular_branches(ax, tree, coords, parent_map,
|
|
132
|
+
color="#333", lw=1.5):
|
|
133
|
+
"""Draw all branches of a circular phylogram.
|
|
134
|
+
|
|
135
|
+
For each non-root clade a *radial* line segment is drawn from the
|
|
136
|
+
parent radius to the child radius at the child's angle. For each
|
|
137
|
+
internal node an *arc* is drawn at the parent's radius connecting the
|
|
138
|
+
children's angles.
|
|
139
|
+
"""
|
|
140
|
+
root = tree.root
|
|
141
|
+
|
|
142
|
+
for clade in tree.find_clades(order="preorder"):
|
|
143
|
+
cid = id(clade)
|
|
144
|
+
if clade == root:
|
|
145
|
+
continue
|
|
146
|
+
parent = parent_map[cid]
|
|
147
|
+
pc = coords[id(parent)]
|
|
148
|
+
cc = coords[cid]
|
|
149
|
+
# Radial segment at child's angle.
|
|
150
|
+
r_parent = pc["radius"]
|
|
151
|
+
r_child = cc["radius"]
|
|
152
|
+
angle = cc["angle"]
|
|
153
|
+
x0 = r_parent * math.cos(angle)
|
|
154
|
+
y0 = r_parent * math.sin(angle)
|
|
155
|
+
x1 = r_child * math.cos(angle)
|
|
156
|
+
y1 = r_child * math.sin(angle)
|
|
157
|
+
ax.plot([x0, x1], [y0, y1], color=color, linewidth=lw,
|
|
158
|
+
solid_capstyle="round")
|
|
159
|
+
|
|
160
|
+
# Arcs for internal nodes.
|
|
161
|
+
for clade in tree.find_clades(order="preorder"):
|
|
162
|
+
if clade.is_terminal() or not clade.clades:
|
|
163
|
+
continue
|
|
164
|
+
pc = coords[id(clade)]
|
|
165
|
+
child_angles = [coords[id(ch)]["angle"] for ch in clade.clades]
|
|
166
|
+
if len(child_angles) < 2:
|
|
167
|
+
continue
|
|
168
|
+
start_a = min(child_angles)
|
|
169
|
+
end_a = max(child_angles)
|
|
170
|
+
span = (end_a - start_a) % (2.0 * math.pi)
|
|
171
|
+
if span > math.pi:
|
|
172
|
+
# Swap so arc takes the long way (the actual subtended arc).
|
|
173
|
+
start_a, end_a = end_a, start_a
|
|
174
|
+
_draw_arc(ax, 0.0, 0.0, pc["radius"], start_a, end_a, color, lw)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def draw_circular_tip_labels(ax, tree, coords, fontsize=9, offset=0.03):
|
|
178
|
+
"""Place tip labels around the circular layout.
|
|
179
|
+
|
|
180
|
+
Labels on the right half (-90 to 90 degrees) are left-aligned with
|
|
181
|
+
rotation following the angle. Labels on the left half are
|
|
182
|
+
right-aligned and rotated by 180 degrees so text always reads
|
|
183
|
+
left-to-right.
|
|
184
|
+
"""
|
|
185
|
+
for tip in tree.get_terminals():
|
|
186
|
+
tc = coords[id(tip)]
|
|
187
|
+
angle = tc["angle"]
|
|
188
|
+
r = tc["radius"] + offset
|
|
189
|
+
x = r * math.cos(angle)
|
|
190
|
+
y = r * math.sin(angle)
|
|
191
|
+
|
|
192
|
+
deg = math.degrees(angle)
|
|
193
|
+
# Normalise to (-180, 180]
|
|
194
|
+
deg = ((deg + 180) % 360) - 180
|
|
195
|
+
|
|
196
|
+
if -90 <= deg <= 90:
|
|
197
|
+
ha = "left"
|
|
198
|
+
rotation = deg
|
|
199
|
+
else:
|
|
200
|
+
ha = "right"
|
|
201
|
+
rotation = deg + 180
|
|
202
|
+
|
|
203
|
+
label = tip.name if tip.name else ""
|
|
204
|
+
ax.text(x, y, label, fontsize=fontsize, ha=ha, va="center",
|
|
205
|
+
rotation=rotation, rotation_mode="anchor")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def draw_circular_colored_branch(ax, parent_coords, child_coords,
|
|
209
|
+
color, lw=1.5):
|
|
210
|
+
"""Draw a single radial segment in a specific colour.
|
|
211
|
+
|
|
212
|
+
Goes from ``parent_radius`` at the child's angle to ``child_radius``
|
|
213
|
+
at the child's angle.
|
|
214
|
+
"""
|
|
215
|
+
angle = child_coords["angle"]
|
|
216
|
+
r_p = parent_coords["radius"]
|
|
217
|
+
r_c = child_coords["radius"]
|
|
218
|
+
x0 = r_p * math.cos(angle)
|
|
219
|
+
y0 = r_p * math.sin(angle)
|
|
220
|
+
x1 = r_c * math.cos(angle)
|
|
221
|
+
y1 = r_c * math.sin(angle)
|
|
222
|
+
ax.plot([x0, x1], [y0, y1], color=color, linewidth=lw,
|
|
223
|
+
solid_capstyle="round")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def draw_circular_colored_arc(ax, cx, cy, radius, start_angle, end_angle,
|
|
227
|
+
color, lw=1.5):
|
|
228
|
+
"""Public wrapper around ``_draw_arc`` for coloured arcs."""
|
|
229
|
+
_draw_arc(ax, cx, cy, radius, start_angle, end_angle, color, lw)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Utility helpers
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def circular_branch_points(parent_coords, child_coords, n_points):
|
|
237
|
+
"""Return *n_points* evenly spaced ``(x, y, angle)`` tuples along a
|
|
238
|
+
radial segment from parent to child.
|
|
239
|
+
"""
|
|
240
|
+
angle = child_coords["angle"]
|
|
241
|
+
r_p = parent_coords["radius"]
|
|
242
|
+
r_c = child_coords["radius"]
|
|
243
|
+
points: List[Tuple[float, float, float]] = []
|
|
244
|
+
for i in range(n_points):
|
|
245
|
+
t = i / max(n_points - 1, 1)
|
|
246
|
+
r = r_p + (r_c - r_p) * t
|
|
247
|
+
points.append((r * math.cos(angle), r * math.sin(angle), angle))
|
|
248
|
+
return points
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def radial_offset(angle, distance):
|
|
252
|
+
"""Return ``(dx, dy)`` for a displacement of *distance* at *angle*."""
|
|
253
|
+
return (math.cos(angle) * distance, math.sin(angle) * distance)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# Gradient and multi-segment branches
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def draw_circular_gradient_branch(ax, parent_coords, child_coords,
|
|
261
|
+
cmap, vmin, vmax, parent_val, child_val,
|
|
262
|
+
lw=1.5):
|
|
263
|
+
"""Draw a radial segment coloured by a continuous gradient.
|
|
264
|
+
|
|
265
|
+
The segment is divided into ~30 sub-segments; each is coloured by
|
|
266
|
+
linearly interpolating *parent_val* to *child_val* through *cmap*.
|
|
267
|
+
"""
|
|
268
|
+
n_seg = 30
|
|
269
|
+
angle = child_coords["angle"]
|
|
270
|
+
r_p = parent_coords["radius"]
|
|
271
|
+
r_c = child_coords["radius"]
|
|
272
|
+
norm_range = vmax - vmin if vmax != vmin else 1.0
|
|
273
|
+
|
|
274
|
+
for i in range(n_seg):
|
|
275
|
+
t0 = i / n_seg
|
|
276
|
+
t1 = (i + 1) / n_seg
|
|
277
|
+
r0 = r_p + (r_c - r_p) * t0
|
|
278
|
+
r1 = r_p + (r_c - r_p) * t1
|
|
279
|
+
val = parent_val + (child_val - parent_val) * (t0 + t1) / 2.0
|
|
280
|
+
normed = (val - vmin) / norm_range
|
|
281
|
+
normed = max(0.0, min(1.0, normed))
|
|
282
|
+
seg_color = cmap(normed)
|
|
283
|
+
x0 = r0 * math.cos(angle)
|
|
284
|
+
y0 = r0 * math.sin(angle)
|
|
285
|
+
x1 = r1 * math.cos(angle)
|
|
286
|
+
y1 = r1 * math.sin(angle)
|
|
287
|
+
ax.plot([x0, x1], [y0, y1], color=seg_color, linewidth=lw,
|
|
288
|
+
solid_capstyle="butt")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def draw_circular_multi_segment_branch(ax, parent_coords, child_coords,
|
|
292
|
+
segments, state_colors, lw=1.5):
|
|
293
|
+
"""Draw a radial segment split into coloured sub-segments.
|
|
294
|
+
|
|
295
|
+
Parameters
|
|
296
|
+
----------
|
|
297
|
+
segments : list of (start_frac, end_frac, state)
|
|
298
|
+
Each tuple gives the fractional start/end along the branch and
|
|
299
|
+
the discrete state label.
|
|
300
|
+
state_colors : dict
|
|
301
|
+
Mapping ``state -> colour``.
|
|
302
|
+
"""
|
|
303
|
+
angle = child_coords["angle"]
|
|
304
|
+
r_p = parent_coords["radius"]
|
|
305
|
+
r_c = child_coords["radius"]
|
|
306
|
+
|
|
307
|
+
for start_frac, end_frac, state in segments:
|
|
308
|
+
r0 = r_p + (r_c - r_p) * start_frac
|
|
309
|
+
r1 = r_p + (r_c - r_p) * end_frac
|
|
310
|
+
x0 = r0 * math.cos(angle)
|
|
311
|
+
y0 = r0 * math.sin(angle)
|
|
312
|
+
x1 = r1 * math.cos(angle)
|
|
313
|
+
y1 = r1 * math.sin(angle)
|
|
314
|
+
ax.plot([x0, x1], [y0, y1], color=state_colors[state],
|
|
315
|
+
linewidth=lw, solid_capstyle="butt")
|
|
@@ -25,6 +25,7 @@ class PlotConfig:
|
|
|
25
25
|
colors: Optional[List[str]] = None
|
|
26
26
|
ladderize: bool = False
|
|
27
27
|
cladogram: bool = False
|
|
28
|
+
circular: bool = False
|
|
28
29
|
|
|
29
30
|
def validate(self) -> None:
|
|
30
31
|
if self.fig_width is not None and self.fig_width <= 0:
|
|
@@ -104,6 +105,10 @@ class PlotConfig:
|
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
def resolve(self, n_rows=None, n_cols=None) -> "PlotConfig":
|
|
108
|
+
if self.circular and self.fig_width is None and self.fig_height is None:
|
|
109
|
+
size = max(8.0, min(14.0, 3.0 + (n_rows or 0) * 0.08))
|
|
110
|
+
self.fig_width = size
|
|
111
|
+
self.fig_height = size
|
|
107
112
|
defaults = PlotConfig.auto_scale(n_rows, n_cols)
|
|
108
113
|
for fld in [
|
|
109
114
|
"fig_width", "fig_height", "ylabel_fontsize", "xlabel_fontsize",
|
|
@@ -191,6 +196,7 @@ class PlotConfig:
|
|
|
191
196
|
colors=colors,
|
|
192
197
|
ladderize=getattr(args, "ladderize", False),
|
|
193
198
|
cladogram=getattr(args, "cladogram", False),
|
|
199
|
+
circular=getattr(args, "circular", False),
|
|
194
200
|
)
|
|
195
201
|
config.validate()
|
|
196
202
|
return config
|
|
@@ -211,6 +217,7 @@ def add_plot_arguments(parser) -> None:
|
|
|
211
217
|
group.add_argument("--colors", type=str, default=None, help="Comma-separated colors (hex or named)")
|
|
212
218
|
group.add_argument("--ladderize", action="store_true", default=False, help="Ladderize (sort) the tree before plotting")
|
|
213
219
|
group.add_argument("--cladogram", action="store_true", default=False, help="Draw cladogram (equal branch lengths, tips aligned) instead of phylogram")
|
|
220
|
+
group.add_argument("--circular", action="store_true", default=False, help="Draw circular (radial/fan) phylogram instead of rectangular")
|
|
214
221
|
|
|
215
222
|
|
|
216
223
|
def compute_node_x_cladogram(tree, parent_map):
|