tree-annotated-plot 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- tree_annotated_plot/_color.py +526 -0
- tree_annotated_plot/_config.py +99 -6
- tree_annotated_plot/_plot.py +361 -41
- tree_annotated_plot/_tree.py +83 -10
- tree_annotated_plot/cli.py +131 -0
- {tree_annotated_plot-0.1.0.dist-info → tree_annotated_plot-0.2.0.dist-info}/METADATA +5 -1
- tree_annotated_plot-0.2.0.dist-info/RECORD +11 -0
- tree_annotated_plot-0.1.0.dist-info/RECORD +0 -10
- {tree_annotated_plot-0.1.0.dist-info → tree_annotated_plot-0.2.0.dist-info}/WHEEL +0 -0
- {tree_annotated_plot-0.1.0.dist-info → tree_annotated_plot-0.2.0.dist-info}/entry_points.txt +0 -0
- {tree_annotated_plot-0.1.0.dist-info → tree_annotated_plot-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Resolve per-node color categories and the associated `alt.Scale` arrays.
|
|
2
|
+
|
|
3
|
+
Public entry point: :func:`compute_node_color_values`.
|
|
4
|
+
|
|
5
|
+
Spec mini-syntax (see `PlotConfig.color_tree_by`):
|
|
6
|
+
|
|
7
|
+
- ``"<field>"`` — look up ``node_attrs[<field>]`` on each node, auto-unwrapping
|
|
8
|
+
the Auspice ``{"value": ...}`` convention. Missing → ``"unknown"``.
|
|
9
|
+
- ``"genotype:<GENE>:<SITE>"`` — single-site genotype state inferred by walking
|
|
10
|
+
``branch_attrs.mutations[<GENE>]`` from the root. Site with zero mutations
|
|
11
|
+
in the tree → all nodes are labeled ``"<no variation>"``.
|
|
12
|
+
- ``"genotype:<GENE>:<SITE1>,<SITE2>,..."`` — haplotype across sites. Per-node
|
|
13
|
+
label is the ``/``-joined letter+site for each *varying* site, in
|
|
14
|
+
user-supplied order. Sites that are invariant in the tree are dropped from
|
|
15
|
+
the label. If all requested sites are invariant, every node gets
|
|
16
|
+
``"<no variation>"``.
|
|
17
|
+
|
|
18
|
+
Color resolution prefers ``meta.colorings[<key>].scale`` from the Auspice JSON
|
|
19
|
+
when defined (matches Nextstrain views). When unset, partial, or missing for
|
|
20
|
+
the requested key, the per-N table in :data:`_AUSPICE_PALETTE` fills in — the
|
|
21
|
+
same hand-tuned categorical palette Auspice's frontend uses for trees that
|
|
22
|
+
don't ship explicit color scales, so output still matches the Nextstrain
|
|
23
|
+
view. Categories are ordered by descending frequency (ties broken
|
|
24
|
+
alphabetically) before being mapped positionally onto the palette, again
|
|
25
|
+
matching Auspice's behavior. ``"unknown"`` always renders as ``#888888``
|
|
26
|
+
(gray) and is reserved — no entry in :data:`_AUSPICE_PALETTE` is gray, so
|
|
27
|
+
a category drawn from the palette can never be confused with missing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Iterator
|
|
36
|
+
|
|
37
|
+
from ._tree import TreeNode
|
|
38
|
+
|
|
39
|
+
# Auspice's frontend categorical palette, indexed by category count.
|
|
40
|
+
# `_AUSPICE_PALETTE[N]` is a hand-tuned `N`-color palette, perceptually spaced
|
|
41
|
+
# along a viridis-like sweep (purple → blue → green → yellow → orange → red);
|
|
42
|
+
# entries 0..36 are reproduced from Auspice's `src/util/globals.js`. We cap at
|
|
43
|
+
# 36 (matching Auspice's own `colors[colors.length - 1]` fallback) and reuse
|
|
44
|
+
# the largest palette for >36-category trees. Reproducing this table lets
|
|
45
|
+
# rendered output match Nextstrain's view of the same tree even when the
|
|
46
|
+
# Auspice JSON omits an explicit `meta.colorings[<key>].scale`.
|
|
47
|
+
#
|
|
48
|
+
# Source: https://github.com/nextstrain/auspice/blob/master/src/util/globals.js
|
|
49
|
+
# Auspice is licensed under AGPL-3.0; the per-N color tables are reproduced
|
|
50
|
+
# here as factual color data with attribution.
|
|
51
|
+
# fmt: off
|
|
52
|
+
_AUSPICE_PALETTE: tuple[tuple[str, ...], ...] = (
|
|
53
|
+
(),
|
|
54
|
+
("#4C90C0",),
|
|
55
|
+
("#4C90C0", "#CBB742"),
|
|
56
|
+
("#4988C5", "#7EB876", "#CBB742"),
|
|
57
|
+
("#4580CA", "#6BB28D", "#AABD52", "#DFA43B"),
|
|
58
|
+
("#4377CD", "#61AB9D", "#94BD61", "#CDB642", "#E68133"),
|
|
59
|
+
("#416DCE", "#59A3AA", "#84BA6F", "#BBBC49", "#E29D39", "#E1502A"),
|
|
60
|
+
("#3F63CF", "#529AB6", "#75B681", "#A6BE55", "#D4B13F", "#E68133", "#DC2F24"),
|
|
61
|
+
("#3E58CF", "#4B8EC1", "#65AE96", "#8CBB69", "#B8BC4A", "#DCAB3C", "#E67932", "#DC2F24"),
|
|
62
|
+
("#3F4DCB", "#4681C9", "#5AA4A8", "#78B67E", "#9EBE5A", "#C5B945", "#E0A23A", "#E67231", "#DC2F24"),
|
|
63
|
+
("#4042C7", "#4274CE", "#5199B7", "#69B091", "#88BB6C", "#ADBD51", "#CEB541", "#E39B39", "#E56C2F", "#DC2F24"),
|
|
64
|
+
("#4137C2", "#4066CF", "#4B8DC2", "#5DA8A3", "#77B67F", "#96BD60", "#B8BC4B", "#D4B13F", "#E59638", "#E4672F", "#DC2F24"),
|
|
65
|
+
("#462EB9", "#3E58CF", "#4580CA", "#549DB2", "#69B091", "#83BA70", "#A2BE57", "#C1BA47", "#D9AD3D", "#E69136", "#E4632E", "#DC2F24"),
|
|
66
|
+
("#4B26B1", "#3F4ACA", "#4272CE", "#4D92BF", "#5DA8A3", "#74B583", "#8EBC66", "#ACBD51", "#C8B944", "#DDA93C", "#E68B35", "#E3602D", "#DC2F24"),
|
|
67
|
+
("#511EA8", "#403DC5", "#4063CF", "#4785C7", "#559EB1", "#67AF94", "#7EB877", "#98BD5E", "#B4BD4C", "#CDB642", "#DFA53B", "#E68735", "#E35D2D", "#DC2F24"),
|
|
68
|
+
("#511EA8", "#403AC4", "#3F5ED0", "#457FCB", "#5098B9", "#60AA9F", "#73B583", "#8BBB6A", "#A4BE56", "#BDBB48", "#D3B240", "#E19F3A", "#E68234", "#E25A2C", "#DC2F24"),
|
|
69
|
+
("#511EA8", "#4138C3", "#3E59CF", "#4379CD", "#4D92BE", "#5AA5A8", "#6BB18E", "#7FB975", "#96BD5F", "#AFBD4F", "#C5B945", "#D8AE3E", "#E39B39", "#E67D33", "#E2572B", "#DC2F24"),
|
|
70
|
+
("#511EA8", "#4236C1", "#3F55CE", "#4273CE", "#4A8CC2", "#569FAF", "#64AD98", "#76B680", "#8BBB6A", "#A1BE58", "#B7BC4B", "#CCB742", "#DCAB3C", "#E59638", "#E67932", "#E1552B", "#DC2F24"),
|
|
71
|
+
("#511EA8", "#4335BF", "#3F51CC", "#416ECE", "#4887C6", "#529BB6", "#5FA9A0", "#6EB389", "#81B973", "#95BD61", "#AABD52", "#BFBB48", "#D1B340", "#DEA63B", "#E69237", "#E67531", "#E1522A", "#DC2F24"),
|
|
72
|
+
("#511EA8", "#4333BE", "#3F4ECB", "#4169CF", "#4682C9", "#4F96BB", "#5AA5A8", "#68AF92", "#78B77D", "#8BBB6A", "#9EBE59", "#B3BD4D", "#C5B945", "#D5B03F", "#E0A23A", "#E68D36", "#E67231", "#E1502A", "#DC2F24"),
|
|
73
|
+
("#511EA8", "#4432BD", "#3F4BCA", "#4065CF", "#447ECC", "#4C91BF", "#56A0AE", "#63AC9A", "#71B486", "#81BA72", "#94BD62", "#A7BE54", "#BABC4A", "#CBB742", "#D9AE3E", "#E29E39", "#E68935", "#E56E30", "#E14F2A", "#DC2F24"),
|
|
74
|
+
("#511EA8", "#4531BC", "#3F48C9", "#3F61D0", "#4379CD", "#4A8CC2", "#539CB4", "#5EA9A2", "#6BB18E", "#7AB77B", "#8BBB6A", "#9CBE5B", "#AFBD4F", "#C0BA47", "#CFB541", "#DCAB3C", "#E39B39", "#E68534", "#E56B2F", "#E04D29", "#DC2F24"),
|
|
75
|
+
("#511EA8", "#4530BB", "#3F46C8", "#3F5ED0", "#4375CD", "#4988C5", "#5098B9", "#5AA5A8", "#66AE95", "#73B583", "#82BA71", "#93BC62", "#A4BE56", "#B5BD4C", "#C5B945", "#D3B240", "#DEA73B", "#E59738", "#E68234", "#E4682F", "#E04C29", "#DC2F24"),
|
|
76
|
+
("#511EA8", "#462FBA", "#3F44C8", "#3E5BD0", "#4270CE", "#4784C8", "#4E95BD", "#57A1AD", "#61AB9C", "#6DB38A", "#7BB879", "#8BBB6A", "#9BBE5C", "#ABBD51", "#BBBC49", "#CBB843", "#D6AF3E", "#DFA43B", "#E69537", "#E67F33", "#E4662E", "#E04A29", "#DC2F24"),
|
|
77
|
+
("#511EA8", "#462EB9", "#4042C7", "#3E58CF", "#416DCE", "#4580CA", "#4C90C0", "#549DB2", "#5DA8A3", "#69B091", "#75B681", "#83BA70", "#92BC63", "#A2BE57", "#B2BD4D", "#C1BA47", "#CEB541", "#D9AD3D", "#E1A03A", "#E69136", "#E67C32", "#E4632E", "#E04929", "#DC2F24"),
|
|
78
|
+
("#511EA8", "#462EB9", "#4040C6", "#3F55CE", "#4169CF", "#447DCC", "#4A8CC2", "#529AB7", "#5AA5A8", "#64AD98", "#70B487", "#7DB878", "#8BBB6A", "#99BD5D", "#A9BD53", "#B7BC4B", "#C5B945", "#D1B340", "#DCAB3C", "#E29D39", "#E68D36", "#E67932", "#E3612D", "#E04828", "#DC2F24"),
|
|
79
|
+
("#511EA8", "#472DB8", "#403EC6", "#3F53CD", "#4066CF", "#4379CD", "#4989C5", "#4F97BB", "#57A1AD", "#61AA9E", "#6BB18E", "#77B67F", "#84BA70", "#92BC64", "#A0BE58", "#AFBD4F", "#BCBB49", "#CAB843", "#D4B13F", "#DEA83C", "#E39B39", "#E68A35", "#E67732", "#E35F2D", "#DF4728", "#DC2F24"),
|
|
80
|
+
("#511EA8", "#472CB7", "#403DC5", "#3F50CC", "#4063CF", "#4375CD", "#4785C7", "#4D93BE", "#559EB1", "#5DA8A3", "#67AF94", "#72B485", "#7EB877", "#8BBB6A", "#98BD5E", "#A6BE55", "#B4BD4C", "#C1BA47", "#CDB642", "#D7AF3E", "#DFA53B", "#E49838", "#E68735", "#E67431", "#E35D2D", "#DF4628", "#DC2F24"),
|
|
81
|
+
("#511EA8", "#482CB7", "#403BC5", "#3F4ECB", "#3F61D0", "#4272CE", "#4682C9", "#4C90C0", "#529BB5", "#5AA5A8", "#63AC9A", "#6DB28B", "#78B77D", "#84BA6F", "#91BC64", "#9EBE59", "#ACBD51", "#B9BC4A", "#C5B945", "#D0B441", "#DAAD3D", "#E0A23A", "#E59637", "#E68434", "#E67231", "#E35C2C", "#DF4528", "#DC2F24"),
|
|
82
|
+
("#511EA8", "#482BB6", "#403AC4", "#3F4CCB", "#3F5ED0", "#426FCE", "#457FCB", "#4A8CC2", "#5098B9", "#58A2AC", "#60AA9F", "#69B091", "#73B583", "#7FB976", "#8BBB6A", "#97BD5F", "#A4BE56", "#B1BD4E", "#BDBB48", "#C9B843", "#D3B240", "#DCAB3C", "#E19F3A", "#E69337", "#E68234", "#E67030", "#E25A2C", "#DF4428", "#DC2F24"),
|
|
83
|
+
("#511EA8", "#482BB6", "#4039C3", "#3F4ACA", "#3E5CD0", "#416CCE", "#447CCD", "#4989C4", "#4E96BC", "#559FB0", "#5DA8A4", "#66AE96", "#6FB388", "#7AB77C", "#85BA6F", "#91BC64", "#9DBE5A", "#AABD53", "#B6BD4B", "#C2BA46", "#CDB642", "#D6B03F", "#DDA83C", "#E29D39", "#E69036", "#E67F33", "#E56D30", "#E2592C", "#DF4428", "#DC2F24"),
|
|
84
|
+
("#511EA8", "#482AB5", "#4138C3", "#3F48C9", "#3E59CF", "#4169CF", "#4379CD", "#4886C6", "#4D92BE", "#539CB4", "#5AA5A8", "#62AB9B", "#6BB18E", "#75B581", "#7FB975", "#8BBB6A", "#96BD5F", "#A2BE57", "#AFBD4F", "#BABC4A", "#C5B945", "#CFB541", "#D8AE3E", "#DFA63B", "#E39B39", "#E68D36", "#E67D33", "#E56B2F", "#E2572B", "#DF4328", "#DC2F24"),
|
|
85
|
+
("#511EA8", "#492AB5", "#4137C2", "#3F47C9", "#3E57CE", "#4067CF", "#4376CD", "#4783C8", "#4C8FC0", "#519AB7", "#58A2AC", "#5FA9A0", "#68AF93", "#70B486", "#7BB77A", "#85BA6F", "#90BC65", "#9CBE5B", "#A8BE54", "#B3BD4D", "#BEBB48", "#C9B843", "#D2B340", "#DAAD3D", "#E0A33B", "#E49838", "#E68B35", "#E67B32", "#E5692F", "#E2562B", "#DF4227", "#DC2F24"),
|
|
86
|
+
("#511EA8", "#492AB5", "#4236C1", "#3F45C8", "#3F55CE", "#4064CF", "#4273CE", "#4681CA", "#4A8CC2", "#4F97BA", "#569FAF", "#5CA7A4", "#64AD98", "#6DB28B", "#76B680", "#80B974", "#8BBB6A", "#96BD60", "#A1BE58", "#ACBD51", "#B7BC4B", "#C2BA46", "#CCB742", "#D4B13F", "#DCAB3C", "#E1A13A", "#E59638", "#E68835", "#E67932", "#E4672F", "#E1552B", "#DF4227", "#DC2F24"),
|
|
87
|
+
("#511EA8", "#4929B4", "#4235C0", "#3F44C8", "#3F53CD", "#3F62CF", "#4270CE", "#457ECB", "#4989C4", "#4E95BD", "#549DB3", "#5AA5A8", "#61AB9C", "#69B090", "#72B485", "#7BB879", "#85BA6E", "#90BC65", "#9BBE5C", "#A6BE55", "#B1BD4E", "#BBBC49", "#C5B945", "#CEB541", "#D6AF3E", "#DDA93C", "#E29F39", "#E69537", "#E68634", "#E67732", "#E4662E", "#E1532B", "#DF4127", "#DC2F24"),
|
|
88
|
+
("#511EA8", "#4929B4", "#4335BF", "#3F42C7", "#3F51CC", "#3F60D0", "#416ECE", "#447CCD", "#4887C6", "#4D92BF", "#529BB6", "#58A2AB", "#5FA9A0", "#66AE95", "#6EB389", "#77B67E", "#81B973", "#8BBB6A", "#95BD61", "#A0BE59", "#AABD52", "#B5BD4C", "#BFBB48", "#C9B843", "#D1B340", "#D8AE3E", "#DEA63B", "#E29C39", "#E69237", "#E68434", "#E67531", "#E4642E", "#E1522A", "#DF4127", "#DC2F24"),
|
|
89
|
+
("#511EA8", "#4928B4", "#4334BF", "#4041C7", "#3F50CC", "#3F5ED0", "#416CCE", "#4379CD", "#4784C7", "#4B8FC1", "#5098B9", "#56A0AF", "#5CA7A4", "#63AC99", "#6BB18E", "#73B583", "#7CB878", "#86BB6E", "#90BC65", "#9ABD5C", "#A4BE56", "#AFBD4F", "#B9BC4A", "#C2BA46", "#CCB742", "#D3B240", "#DAAC3D", "#DFA43B", "#E39B39", "#E68F36", "#E68234", "#E67431", "#E4632E", "#E1512A", "#DF4027", "#DC2F24"),
|
|
90
|
+
)
|
|
91
|
+
# fmt: on
|
|
92
|
+
|
|
93
|
+
_UNKNOWN = "unknown"
|
|
94
|
+
_NO_VARIATION = "<no variation>"
|
|
95
|
+
_GRAY = "#888888"
|
|
96
|
+
|
|
97
|
+
# Auspice mutation strings are like "N158K" / "*123A" / "-456N": one non-digit
|
|
98
|
+
# char, then digits, then one non-digit char.
|
|
99
|
+
_MUTATION_RE = re.compile(r"^(\D)(\d+)(\D)$")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class ColorMapping:
|
|
104
|
+
"""Resolved color information for a single `color_tree_by` invocation."""
|
|
105
|
+
|
|
106
|
+
values_by_node: dict[str, str]
|
|
107
|
+
domain: list[str]
|
|
108
|
+
range_: list[str]
|
|
109
|
+
legend_title: str
|
|
110
|
+
# When None, the legend shows the full domain. When set, it restricts the
|
|
111
|
+
# legend display without altering the scale — used to hide ``"unknown"``
|
|
112
|
+
# when only internal nodes (not tips) lack the attribute, since the gray
|
|
113
|
+
# entry in that case just flags internal-node bookkeeping rather than
|
|
114
|
+
# any missing tip-level data.
|
|
115
|
+
legend_values: list[str] | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def compute_node_color_values(
|
|
119
|
+
root: TreeNode,
|
|
120
|
+
color_spec: str,
|
|
121
|
+
auspice_meta: dict | None = None,
|
|
122
|
+
tree_color_scale: dict[str, str] | None = None,
|
|
123
|
+
) -> ColorMapping:
|
|
124
|
+
"""Walk the tree and resolve per-node color categories + scale arrays.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
root
|
|
129
|
+
The root of the tree to color. Internal-node identity is by
|
|
130
|
+
``TreeNode.name`` (Auspice's ``NODE_xxxx``); tips by their resolved
|
|
131
|
+
strain name.
|
|
132
|
+
color_spec
|
|
133
|
+
The user-supplied spec string (see module docstring).
|
|
134
|
+
auspice_meta
|
|
135
|
+
The Auspice JSON's top-level ``meta`` dict, or ``None`` when no JSON
|
|
136
|
+
is available (caller passed a pre-built `TreeNode`). Used only to
|
|
137
|
+
consult ``meta.colorings[<key>].scale`` and ``.title`` for node-attr
|
|
138
|
+
specs; ignored for genotype specs.
|
|
139
|
+
tree_color_scale
|
|
140
|
+
Optional user-supplied {category: color} mapping. When provided, its
|
|
141
|
+
keys must match the tree's real categories one-to-one (mismatch is a
|
|
142
|
+
``ValueError``); the legend order is the dict's insertion order.
|
|
143
|
+
``"unknown"`` is always gray and must not be specified.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
ColorMapping
|
|
148
|
+
``values_by_node[node.name]`` is the category string for each node.
|
|
149
|
+
``domain`` and ``range_`` are parallel lists for ``alt.Scale``.
|
|
150
|
+
``legend_title`` is the resolved legend header. ``legend_values``,
|
|
151
|
+
when set, restricts which categories appear in the legend (leaving
|
|
152
|
+
the scale untouched).
|
|
153
|
+
"""
|
|
154
|
+
parsed = _parse_color_spec(color_spec)
|
|
155
|
+
if parsed[0] == "attr":
|
|
156
|
+
_, key = parsed
|
|
157
|
+
values_by_node = _color_by_node_attr(root, key)
|
|
158
|
+
else:
|
|
159
|
+
_, gene, sites = parsed
|
|
160
|
+
values_by_node = _color_by_genotype(root, gene, sites)
|
|
161
|
+
|
|
162
|
+
categories = _ordered_categories(values_by_node.values())
|
|
163
|
+
if tree_color_scale is not None:
|
|
164
|
+
domain, range_ = _apply_user_scale(categories, tree_color_scale)
|
|
165
|
+
else:
|
|
166
|
+
domain, range_ = _resolve_scale(categories, parsed, auspice_meta)
|
|
167
|
+
legend_title = _resolve_legend_title(color_spec, parsed, auspice_meta)
|
|
168
|
+
legend_values = _resolve_legend_values(domain, values_by_node, root)
|
|
169
|
+
return ColorMapping(
|
|
170
|
+
values_by_node=values_by_node,
|
|
171
|
+
domain=domain,
|
|
172
|
+
range_=range_,
|
|
173
|
+
legend_title=legend_title,
|
|
174
|
+
legend_values=legend_values,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _apply_user_scale(
|
|
179
|
+
categories: list[str],
|
|
180
|
+
user_scale: dict[str, str],
|
|
181
|
+
) -> tuple[list[str], list[str]]:
|
|
182
|
+
"""Build (domain, range_) from a user-supplied {category: color} dict.
|
|
183
|
+
|
|
184
|
+
The user's key order is the legend order (Python dicts preserve insertion
|
|
185
|
+
order). Keys must match the tree's real categories one-to-one — extras or
|
|
186
|
+
misses raise ``ValueError`` listing the actual tree categories so the
|
|
187
|
+
user can copy-paste the correct keys (especially relevant for
|
|
188
|
+
genotype/haplotype categories like ``"K158"`` or ``"K158/E189"`` whose
|
|
189
|
+
exact form depends on the data). ``"unknown"`` is always gray and is
|
|
190
|
+
appended automatically when present in ``categories`` — the user must
|
|
191
|
+
not include it.
|
|
192
|
+
"""
|
|
193
|
+
real_categories = [c for c in categories if c != _UNKNOWN]
|
|
194
|
+
user_keys = list(user_scale.keys())
|
|
195
|
+
|
|
196
|
+
if _UNKNOWN in user_keys:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"tree_color_scale must not contain key {_UNKNOWN!r}; "
|
|
199
|
+
"missing values are always rendered gray."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
tree_set = set(real_categories)
|
|
203
|
+
user_set = set(user_keys)
|
|
204
|
+
missing = sorted(tree_set - user_set)
|
|
205
|
+
extra = sorted(user_set - tree_set)
|
|
206
|
+
if missing or extra:
|
|
207
|
+
msg = (
|
|
208
|
+
"tree_color_scale keys don't match the tree's categories.\n"
|
|
209
|
+
f" Tree categories: {real_categories!r}\n"
|
|
210
|
+
f" Provided keys: {user_keys!r}"
|
|
211
|
+
)
|
|
212
|
+
if missing:
|
|
213
|
+
msg += f"\n Missing from your scale: {missing!r}"
|
|
214
|
+
if extra:
|
|
215
|
+
msg += f"\n Unexpected in your scale: {extra!r}"
|
|
216
|
+
raise ValueError(msg)
|
|
217
|
+
|
|
218
|
+
domain = list(user_keys)
|
|
219
|
+
range_ = [user_scale[k] for k in user_keys]
|
|
220
|
+
if _UNKNOWN in categories:
|
|
221
|
+
domain.append(_UNKNOWN)
|
|
222
|
+
range_.append(_GRAY)
|
|
223
|
+
return domain, range_
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_legend_values(
|
|
227
|
+
domain: list[str],
|
|
228
|
+
values_by_node: dict[str, str],
|
|
229
|
+
root: TreeNode,
|
|
230
|
+
) -> list[str] | None:
|
|
231
|
+
"""Decide whether to hide ``"unknown"`` from the legend.
|
|
232
|
+
|
|
233
|
+
Returns ``None`` when the full domain should appear — either because
|
|
234
|
+
``"unknown"`` isn't a category at all, or because at least one tip
|
|
235
|
+
carries it (in which case the user looking at a gray tip needs the
|
|
236
|
+
legend to explain it). When ``"unknown"`` is present but only on
|
|
237
|
+
internal nodes, returns the domain with ``"unknown"`` filtered out;
|
|
238
|
+
internal segments still render gray via the unchanged scale, but the
|
|
239
|
+
legend doesn't dangle a misleading entry.
|
|
240
|
+
"""
|
|
241
|
+
if _UNKNOWN not in domain:
|
|
242
|
+
return None
|
|
243
|
+
for node in _walk_nodes(root):
|
|
244
|
+
if node.is_tip and values_by_node.get(node.name) == _UNKNOWN:
|
|
245
|
+
return None
|
|
246
|
+
return [c for c in domain if c != _UNKNOWN]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_color_spec(
|
|
250
|
+
spec: str,
|
|
251
|
+
) -> tuple[str, str] | tuple[str, str, list[int]]:
|
|
252
|
+
"""Parse the spec mini-syntax. See module docstring for the grammar."""
|
|
253
|
+
if not isinstance(spec, str) or not spec or any(c.isspace() for c in spec):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"color_tree_by={spec!r}: expected either a node_attrs key, "
|
|
256
|
+
'"genotype:<GENE>:<SITE>", or '
|
|
257
|
+
'"genotype:<GENE>:<SITE1>,<SITE2>,...".'
|
|
258
|
+
)
|
|
259
|
+
if ":" not in spec:
|
|
260
|
+
return ("attr", spec)
|
|
261
|
+
parts = spec.split(":")
|
|
262
|
+
if len(parts) != 3 or parts[0] != "genotype" or not parts[1] or not parts[2]:
|
|
263
|
+
raise ValueError(
|
|
264
|
+
f"color_tree_by={spec!r}: expected either a node_attrs key, "
|
|
265
|
+
'"genotype:<GENE>:<SITE>", or '
|
|
266
|
+
'"genotype:<GENE>:<SITE1>,<SITE2>,...".'
|
|
267
|
+
)
|
|
268
|
+
_, gene, site_str = parts
|
|
269
|
+
site_strs = site_str.split(",")
|
|
270
|
+
sites: list[int] = []
|
|
271
|
+
for s in site_strs:
|
|
272
|
+
try:
|
|
273
|
+
n = int(s)
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"color_tree_by={spec!r}: site {s!r} must be a positive integer."
|
|
277
|
+
) from e
|
|
278
|
+
if n <= 0:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"color_tree_by={spec!r}: site {s!r} must be a positive integer."
|
|
281
|
+
)
|
|
282
|
+
sites.append(n)
|
|
283
|
+
seen: set[int] = set()
|
|
284
|
+
dups: set[int] = set()
|
|
285
|
+
for n in sites:
|
|
286
|
+
if n in seen:
|
|
287
|
+
dups.add(n)
|
|
288
|
+
else:
|
|
289
|
+
seen.add(n)
|
|
290
|
+
if dups:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"color_tree_by={spec!r}: site list has duplicates ({sorted(dups)}); "
|
|
293
|
+
"each site must appear at most once."
|
|
294
|
+
)
|
|
295
|
+
return ("genotype", gene, sites)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _walk_nodes(root: TreeNode) -> Iterator[TreeNode]:
|
|
299
|
+
"""Yield every node in the tree in pre-order."""
|
|
300
|
+
yield root
|
|
301
|
+
for c in root.children:
|
|
302
|
+
yield from _walk_nodes(c)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _color_by_node_attr(root: TreeNode, key: str) -> dict[str, str]:
|
|
306
|
+
"""Resolve per-node category by reading `node_attrs[key]`."""
|
|
307
|
+
values: dict[str, str] = {}
|
|
308
|
+
found = False
|
|
309
|
+
observed_keys: set[str] = set()
|
|
310
|
+
for node in _walk_nodes(root):
|
|
311
|
+
observed_keys.update(node.node_attrs.keys())
|
|
312
|
+
attr = node.node_attrs.get(key)
|
|
313
|
+
if attr is None:
|
|
314
|
+
values[node.name] = _UNKNOWN
|
|
315
|
+
continue
|
|
316
|
+
if isinstance(attr, dict) and "value" in attr:
|
|
317
|
+
attr = attr["value"]
|
|
318
|
+
if attr is None or attr == "":
|
|
319
|
+
values[node.name] = _UNKNOWN
|
|
320
|
+
continue
|
|
321
|
+
found = True
|
|
322
|
+
values[node.name] = str(attr)
|
|
323
|
+
if not found:
|
|
324
|
+
observed = ", ".join(repr(k) for k in sorted(observed_keys))
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"color_tree_by={key!r} is not a node_attrs key in this tree. "
|
|
327
|
+
f"Observed keys: [{observed}]"
|
|
328
|
+
)
|
|
329
|
+
return values
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _color_by_genotype(root: TreeNode, gene: str, sites: list[int]) -> dict[str, str]:
|
|
333
|
+
"""Resolve per-node category by walking branch_attrs.mutations[gene].
|
|
334
|
+
|
|
335
|
+
See :func:`_color_by_genotype_single_site` for the per-site walk.
|
|
336
|
+
Per-node label is the ``/``-joined ``<letter><site>`` for each varying
|
|
337
|
+
site in user-supplied order; if every requested site is invariant, every
|
|
338
|
+
node gets ``"<no variation>"``.
|
|
339
|
+
"""
|
|
340
|
+
observed_genes: set[str] = set()
|
|
341
|
+
any_mutation = False
|
|
342
|
+
for node in _walk_nodes(root):
|
|
343
|
+
gene_map = node.branch_attrs.get("mutations", {}) or {}
|
|
344
|
+
if gene_map:
|
|
345
|
+
any_mutation = True
|
|
346
|
+
observed_genes.update(gene_map.keys())
|
|
347
|
+
if not any_mutation:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"color_tree_by='genotype:{gene}:{','.join(map(str, sites))}': "
|
|
350
|
+
"the Auspice JSON has no branch_attrs.mutations annotations."
|
|
351
|
+
)
|
|
352
|
+
if gene not in observed_genes:
|
|
353
|
+
observed = ", ".join(repr(g) for g in sorted(observed_genes))
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"color_tree_by='genotype:{gene}:{','.join(map(str, sites))}': "
|
|
356
|
+
f"gene {gene!r} not in branch_attrs.mutations. "
|
|
357
|
+
f"Observed genes: [{observed}]"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
per_site_states: dict[int, dict[str, str] | None] = {}
|
|
361
|
+
for site in sites:
|
|
362
|
+
per_site_states[site] = _color_by_genotype_single_site(root, gene, site)
|
|
363
|
+
|
|
364
|
+
varying_sites = [s for s in sites if per_site_states[s] is not None]
|
|
365
|
+
if not varying_sites:
|
|
366
|
+
# Every requested site is invariant in the tree.
|
|
367
|
+
return {node.name: _NO_VARIATION for node in _walk_nodes(root)}
|
|
368
|
+
|
|
369
|
+
values: dict[str, str] = {}
|
|
370
|
+
for node in _walk_nodes(root):
|
|
371
|
+
parts = []
|
|
372
|
+
for site in varying_sites:
|
|
373
|
+
states = per_site_states[site]
|
|
374
|
+
assert states is not None
|
|
375
|
+
parts.append(f"{states[node.name]}{site}")
|
|
376
|
+
values[node.name] = "/".join(parts)
|
|
377
|
+
return values
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _color_by_genotype_single_site(
|
|
381
|
+
root: TreeNode, gene: str, site: int
|
|
382
|
+
) -> dict[str, str] | None:
|
|
383
|
+
"""Pre-order walk inferring the state at (gene, site) for every node.
|
|
384
|
+
|
|
385
|
+
Returns ``None`` when the site has zero mutations in the tree (invariant —
|
|
386
|
+
the caller drops it from the haplotype label, or labels every node
|
|
387
|
+
``"<no variation>"`` when *all* requested sites are invariant).
|
|
388
|
+
Otherwise returns ``{node.name: letter}``.
|
|
389
|
+
"""
|
|
390
|
+
# Pass 1: collect every (gene, site) mutation along with the path-from-root
|
|
391
|
+
# depth at which it was applied. The earliest mutation's "from" letter is
|
|
392
|
+
# the root's state at the site.
|
|
393
|
+
mutations_seen: list[tuple[int, str, str]] = [] # (depth, from_letter, to_letter)
|
|
394
|
+
|
|
395
|
+
def collect(node: TreeNode, depth: int) -> None:
|
|
396
|
+
for mut in node.branch_attrs.get("mutations", {}).get(gene, []) or []:
|
|
397
|
+
m = _MUTATION_RE.match(mut)
|
|
398
|
+
if m is None:
|
|
399
|
+
continue
|
|
400
|
+
from_letter, site_str, to_letter = m.group(1), m.group(2), m.group(3)
|
|
401
|
+
if int(site_str) == site:
|
|
402
|
+
mutations_seen.append((depth, from_letter, to_letter))
|
|
403
|
+
for c in node.children:
|
|
404
|
+
collect(c, depth + 1)
|
|
405
|
+
|
|
406
|
+
collect(root, 0)
|
|
407
|
+
if not mutations_seen:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
mutations_seen.sort(key=lambda t: t[0])
|
|
411
|
+
root_letter = mutations_seen[0][1]
|
|
412
|
+
|
|
413
|
+
# Pass 2: assign per-node state.
|
|
414
|
+
states: dict[str, str] = {}
|
|
415
|
+
|
|
416
|
+
def assign(node: TreeNode, current: str) -> None:
|
|
417
|
+
for mut in node.branch_attrs.get("mutations", {}).get(gene, []) or []:
|
|
418
|
+
m = _MUTATION_RE.match(mut)
|
|
419
|
+
if m is None:
|
|
420
|
+
continue
|
|
421
|
+
site_str, to_letter = m.group(2), m.group(3)
|
|
422
|
+
if int(site_str) == site:
|
|
423
|
+
current = to_letter
|
|
424
|
+
states[node.name] = current
|
|
425
|
+
for c in node.children:
|
|
426
|
+
assign(c, current)
|
|
427
|
+
|
|
428
|
+
assign(root, root_letter)
|
|
429
|
+
return states
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _ordered_categories(values: Iterator[str] | list[str]) -> list[str]:
|
|
433
|
+
"""Return unique category labels in legend display order.
|
|
434
|
+
|
|
435
|
+
Real categories are sorted by descending count (ties broken
|
|
436
|
+
alphabetically), matching Auspice's `sortedDomain` for non-
|
|
437
|
+
clade-membership traits — the most common category lands at index 0
|
|
438
|
+
so it gets the first slot of :data:`_AUSPICE_PALETTE[N]`.
|
|
439
|
+
``"unknown"`` (if present) is pinned to the end regardless of count.
|
|
440
|
+
``"<no variation>"`` is only ever the sole category (when every
|
|
441
|
+
requested site is invariant); in that case it stands alone.
|
|
442
|
+
"""
|
|
443
|
+
counts = Counter(values)
|
|
444
|
+
if set(counts) == {_NO_VARIATION}:
|
|
445
|
+
return [_NO_VARIATION]
|
|
446
|
+
has_unknown = _UNKNOWN in counts
|
|
447
|
+
real = [c for c in counts if c != _UNKNOWN]
|
|
448
|
+
real.sort(key=lambda c: (-counts[c], c))
|
|
449
|
+
if has_unknown:
|
|
450
|
+
real.append(_UNKNOWN)
|
|
451
|
+
return real
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _resolve_scale(
|
|
455
|
+
categories: list[str],
|
|
456
|
+
parsed_spec: tuple,
|
|
457
|
+
auspice_meta: dict | None,
|
|
458
|
+
) -> tuple[list[str], list[str]]:
|
|
459
|
+
"""Build (domain, range_) parallel arrays for `alt.Scale`.
|
|
460
|
+
|
|
461
|
+
Prefers ``meta.colorings[<key>].scale`` for node-attr specs. Categories
|
|
462
|
+
not covered by the auspice scale (and every category for genotype specs
|
|
463
|
+
or when no `auspice_meta` is supplied) fall back to
|
|
464
|
+
:data:`_AUSPICE_PALETTE[K]`, where K is the number of *unmapped*
|
|
465
|
+
categories — so the fallback hues come from the same per-N palette
|
|
466
|
+
Auspice uses, capped at 36. ``"unknown"`` always maps to
|
|
467
|
+
:data:`_GRAY`.
|
|
468
|
+
"""
|
|
469
|
+
auspice_map: dict[str, str] = {}
|
|
470
|
+
if parsed_spec[0] == "attr" and auspice_meta is not None:
|
|
471
|
+
key = parsed_spec[1]
|
|
472
|
+
for c in auspice_meta.get("colorings", []) or []:
|
|
473
|
+
if c.get("key") == key:
|
|
474
|
+
scale = c.get("scale") or []
|
|
475
|
+
for entry in scale:
|
|
476
|
+
# Each entry is [value, color]; skip malformed rows.
|
|
477
|
+
if (
|
|
478
|
+
isinstance(entry, (list, tuple))
|
|
479
|
+
and len(entry) == 2
|
|
480
|
+
and isinstance(entry[0], str)
|
|
481
|
+
and isinstance(entry[1], str)
|
|
482
|
+
):
|
|
483
|
+
auspice_map[entry[0]] = entry[1]
|
|
484
|
+
break
|
|
485
|
+
|
|
486
|
+
real_categories = [c for c in categories if c != _UNKNOWN]
|
|
487
|
+
unmapped = [c for c in real_categories if c not in auspice_map]
|
|
488
|
+
palette_idx = min(len(unmapped), len(_AUSPICE_PALETTE) - 1)
|
|
489
|
+
fallback_palette = _AUSPICE_PALETTE[palette_idx]
|
|
490
|
+
|
|
491
|
+
domain: list[str] = []
|
|
492
|
+
range_: list[str] = []
|
|
493
|
+
fallback_pos = 0
|
|
494
|
+
for cat in categories:
|
|
495
|
+
if cat == _UNKNOWN:
|
|
496
|
+
domain.append(cat)
|
|
497
|
+
range_.append(_GRAY)
|
|
498
|
+
continue
|
|
499
|
+
domain.append(cat)
|
|
500
|
+
if cat in auspice_map:
|
|
501
|
+
range_.append(auspice_map[cat])
|
|
502
|
+
else:
|
|
503
|
+
if fallback_palette:
|
|
504
|
+
range_.append(fallback_palette[fallback_pos % len(fallback_palette)])
|
|
505
|
+
else:
|
|
506
|
+
range_.append(_GRAY)
|
|
507
|
+
fallback_pos += 1
|
|
508
|
+
return domain, range_
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _resolve_legend_title(
|
|
512
|
+
color_spec: str,
|
|
513
|
+
parsed_spec: tuple,
|
|
514
|
+
auspice_meta: dict | None,
|
|
515
|
+
) -> str:
|
|
516
|
+
"""Use `meta.colorings[<key>].title` for attr specs when present; else
|
|
517
|
+
the literal spec string."""
|
|
518
|
+
if parsed_spec[0] == "attr" and auspice_meta is not None:
|
|
519
|
+
key = parsed_spec[1]
|
|
520
|
+
for c in auspice_meta.get("colorings", []) or []:
|
|
521
|
+
if c.get("key") == key:
|
|
522
|
+
title = c.get("title")
|
|
523
|
+
if isinstance(title, str) and title:
|
|
524
|
+
return title
|
|
525
|
+
break
|
|
526
|
+
return color_spec
|