tree-annotated-plot 0.0.1__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.
@@ -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