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.
Files changed (126) hide show
  1. {phykit-2.1.51 → phykit-2.1.52}/PKG-INFO +1 -1
  2. phykit-2.1.52/phykit/helpers/circular_layout.py +315 -0
  3. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/plot_config.py +7 -0
  4. {phykit-2.1.51 → phykit-2.1.52}/phykit/phykit.py +141 -29
  5. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ancestral_reconstruction.py +256 -126
  6. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/character_map.py +221 -117
  7. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/concordance_asr.py +108 -55
  8. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/cont_map.py +120 -53
  9. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/cophylo.py +84 -0
  10. phykit-2.1.52/phykit/services/tree/density_map.py +486 -0
  11. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/discordance_asymmetry.py +180 -74
  12. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylo_heatmap.py +139 -0
  13. phykit-2.1.52/phykit/services/tree/quartet_pie.py +410 -0
  14. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rate_heterogeneity.py +123 -47
  15. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/stochastic_character_map.py +160 -60
  16. phykit-2.1.52/phykit/version.py +1 -0
  17. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/PKG-INFO +1 -1
  18. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/SOURCES.txt +1 -0
  19. phykit-2.1.51/phykit/services/tree/density_map.py +0 -362
  20. phykit-2.1.51/phykit/services/tree/quartet_pie.py +0 -310
  21. phykit-2.1.51/phykit/version.py +0 -1
  22. {phykit-2.1.51 → phykit-2.1.52}/LICENSE.md +0 -0
  23. {phykit-2.1.51 → phykit-2.1.52}/README.md +0 -0
  24. {phykit-2.1.51 → phykit-2.1.52}/phykit/__init__.py +0 -0
  25. {phykit-2.1.51 → phykit-2.1.52}/phykit/__main__.py +0 -0
  26. {phykit-2.1.51 → phykit-2.1.52}/phykit/cli_registry.py +0 -0
  27. {phykit-2.1.51 → phykit-2.1.52}/phykit/errors.py +0 -0
  28. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/__init__.py +0 -0
  29. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/boolean_argument_parsing.py +0 -0
  30. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/caching.py +0 -0
  31. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/discrete_models.py +0 -0
  32. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/files.py +0 -0
  33. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/json_output.py +0 -0
  34. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/parallel.py +0 -0
  35. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/parsimony_utils.py +0 -0
  36. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/quartet_utils.py +0 -0
  37. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/stats_summary.py +0 -0
  38. {phykit-2.1.51 → phykit-2.1.52}/phykit/helpers/streaming.py +0 -0
  39. {phykit-2.1.51 → phykit-2.1.52}/phykit/service_factories.py +0 -0
  40. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/__init__.py +0 -0
  41. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/__init__.py +0 -0
  42. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_entropy.py +0 -0
  43. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_length.py +0 -0
  44. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_length_no_gaps.py +0 -0
  45. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_outlier_taxa.py +0 -0
  46. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/alignment_recoding.py +0 -0
  47. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/base.py +0 -0
  48. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/column_score.py +0 -0
  49. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/composition_per_taxon.py +0 -0
  50. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/compositional_bias_per_site.py +0 -0
  51. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/create_concatenation_matrix.py +0 -0
  52. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/dna_threader.py +0 -0
  53. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/evolutionary_rate_per_site.py +0 -0
  54. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/faidx.py +0 -0
  55. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/gc_content.py +0 -0
  56. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/mask_alignment.py +0 -0
  57. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/occupancy_per_taxon.py +0 -0
  58. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/pairwise_identity.py +0 -0
  59. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/parsimony_informative_sites.py +0 -0
  60. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/plot_alignment_qc.py +0 -0
  61. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rcv.py +0 -0
  62. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rcvt.py +0 -0
  63. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/rename_fasta_entries.py +0 -0
  64. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/sum_of_pairs_score.py +0 -0
  65. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/alignment/variable_sites.py +0 -0
  66. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/base.py +0 -0
  67. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/__init__.py +0 -0
  68. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/base.py +0 -0
  69. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/bipartition_support_stats.py +0 -0
  70. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/branch_length_multiplier.py +0 -0
  71. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/collapse_branches.py +0 -0
  72. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/consensus_network.py +0 -0
  73. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/consensus_tree.py +0 -0
  74. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/covarying_evolutionary_rates.py +0 -0
  75. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/dvmc.py +0 -0
  76. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/evo_tempo_map.py +0 -0
  77. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/evolutionary_rate.py +0 -0
  78. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/fit_continuous.py +0 -0
  79. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/fit_discrete.py +0 -0
  80. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/hidden_paralogy_check.py +0 -0
  81. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/independent_contrasts.py +0 -0
  82. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/internal_branch_stats.py +0 -0
  83. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/internode_labeler.py +0 -0
  84. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/kf_distance.py +0 -0
  85. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/last_common_ancestor_subtree.py +0 -0
  86. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/lb_score.py +0 -0
  87. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ltt.py +0 -0
  88. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/monophyly_check.py +0 -0
  89. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/nearest_neighbor_interchange.py +0 -0
  90. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/network_signal.py +0 -0
  91. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ou_shift_detection.py +0 -0
  92. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/ouwie.py +0 -0
  93. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/parsimony_score.py +0 -0
  94. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/patristic_distances.py +0 -0
  95. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phenogram.py +0 -0
  96. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_glm.py +0 -0
  97. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_ordination.py +0 -0
  98. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_regression.py +0 -0
  99. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylogenetic_signal.py +0 -0
  100. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/phylomorphospace.py +0 -0
  101. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/polytomy_test.py +0 -0
  102. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/print_tree.py +0 -0
  103. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/prune_tree.py +0 -0
  104. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/quartet_network.py +0 -0
  105. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/relative_rate_test.py +0 -0
  106. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rename_tree_tips.py +0 -0
  107. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/rf_distance.py +0 -0
  108. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/root_tree.py +0 -0
  109. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/saturation.py +0 -0
  110. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/spectral_discordance.py +0 -0
  111. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/spurious_sequence.py +0 -0
  112. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/terminal_branch_stats.py +0 -0
  113. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/threshold_model.py +0 -0
  114. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_labels.py +0 -0
  115. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_to_tip_distance.py +0 -0
  116. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/tip_to_tip_node_distance.py +0 -0
  117. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/total_tree_length.py +0 -0
  118. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/treeness.py +0 -0
  119. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/treeness_over_rcv.py +0 -0
  120. {phykit-2.1.51 → phykit-2.1.52}/phykit/services/tree/vcv_utils.py +0 -0
  121. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/dependency_links.txt +0 -0
  122. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/entry_points.txt +0 -0
  123. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/requires.txt +0 -0
  124. {phykit-2.1.51 → phykit-2.1.52}/phykit.egg-info/top_level.txt +0 -0
  125. {phykit-2.1.51 → phykit-2.1.52}/setup.cfg +0 -0
  126. {phykit-2.1.51 → phykit-2.1.52}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: phykit
3
- Version: 2.1.51
3
+ Version: 2.1.52
4
4
  Home-page: https://github.com/jlsteenwyk/phykit
5
5
  Author: Jacob L. Steenwyk
6
6
  Author-email: jlsteenwyk@gmail.com
@@ -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):