tree-annotated-plot 0.4.0__tar.gz → 0.5.0__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 (45) hide show
  1. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/CHANGELOG.md +36 -1
  2. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/CLAUDE.md +14 -2
  3. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/PKG-INFO +1 -1
  4. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/examples.md +71 -0
  5. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/flu-seqneut-2025to2026_titer_charts.py +11 -0
  6. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/pyproject.toml +1 -1
  7. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/generate_docs_assets.py +43 -0
  8. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_config.py +41 -4
  9. tree_annotated_plot-0.5.0/src/tree_annotated_plot/_label_style.py +349 -0
  10. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_plot.py +81 -63
  11. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/cli.py +62 -12
  12. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_chart_loader.py +108 -0
  13. tree_annotated_plot-0.5.0/tests/test_strain_label_styles.py +565 -0
  14. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/docs.yml +0 -0
  15. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/release.yml +0 -0
  16. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/tests.yml +0 -0
  17. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.gitignore +0 -0
  18. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/LICENSE +0 -0
  19. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/README.md +0 -0
  20. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/cli.md +0 -0
  21. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/index.md +0 -0
  22. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/python-api.md +0 -0
  23. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/fetch_auspice_data.py +0 -0
  24. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/synthetic_example.py +0 -0
  25. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/mkdocs.yml +0 -0
  26. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/build_docs.sh +0 -0
  27. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/check.sh +0 -0
  28. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/__init__.py +0 -0
  29. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_color.py +0 -0
  30. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_tree.py +0 -0
  31. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_appearance_tuning.py +0 -0
  32. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_branch_length.py +0 -0
  33. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_cli.py +0 -0
  34. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_color_tree.py +0 -0
  35. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_examples_run.py +0 -0
  36. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_introspection.py +0 -0
  37. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_label_connection.py +0 -0
  38. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_line_order.py +0 -0
  39. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_plot.py +0 -0
  40. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_plot_config.py +0 -0
  41. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_real_data.py +0 -0
  42. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_reconciliation.py +0 -0
  43. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_scale_bar.py +0 -0
  44. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_style.py +0 -0
  45. {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_tree_location.py +0 -0
@@ -5,7 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.5.0] - 2026-10-30
9
+
10
+ ### Added
11
+
12
+ - `strain_label_styles` (default `None`): style a subset of the strain labels
13
+ rendered in the tree panel when `connect_leader_to_label` is on, e.g. to draw
14
+ the vaccine strains' labels in bold red. A list of rules, each naming some
15
+ `strains` and a `style` of Vega-Lite text properties (`fill`, `opacity`,
16
+ `fontWeight`, `fontStyle`). Where rules overlap, later rules win property by
17
+ property. An optional `when` condition applies a rule only while it holds:
18
+ - the name of a parameter declared in the chart, such as a checkbox;
19
+ - any Vega expression, e.g. `"emphasis == 'cell'"` for a radio button;
20
+ - in Python, an `alt.Parameter`, which is declared on the returned chart if
21
+ the chart doesn't already declare it.
22
+
23
+ Each styled property becomes a per-label expression on the existing label
24
+ layers, so no layers are added and the returned chart keeps its shape. A
25
+ strain that isn't a tree tip raises; strains dropped by `prune_tree_to_chart`
26
+ are skipped. CLI form: `--strain-label-styles` with a JSON array, where
27
+ `when` names a parameter saved in the chart JSON.
28
+ ([#12](https://github.com/jbloomlab/tree-annotated-plot/issues/12))
29
+
30
+ ### Fixed
31
+
32
+ - A chart with selection parameters on a compound chart (layer, facet, or
33
+ concat) failed to render when passed to `plot()` as a JSON path, HTML path
34
+ or spec dict, which is every CLI use. Vega-Lite reported
35
+ `Duplicate signal name: "<selection>_tuple"`. This affected the flu examples'
36
+ CLI reproductions. The chart loader removed each selection's `views` before
37
+ loading, and altair then emitted empty `views`, which makes Vega-Lite define
38
+ the selection in every view. The loader now leaves the spec's `views` intact.
39
+ Live altair charts were never affected.
40
+ - The `plot()` docstring's Returns section described the panel order wrongly
41
+ for a strain on `x`. The default `tree_location="bottom"` puts the chart
42
+ first and the tree second. The section now gives the order for each
43
+ `tree_location`.
9
44
 
10
45
  ## [0.4.0] - 2026-08-20
11
46
 
@@ -7,7 +7,9 @@
7
7
  section as you make them — new parameters, behavior changes, removed
8
8
  features, bug fixes — in [Keep a Changelog](https://keepachangelog.com/)
9
9
  format. At release time, `## [Unreleased]` is renamed to
10
- `## [X.Y.Z] - YYYY-MM-DD` and a fresh `## [Unreleased]` is created.
10
+ `## [X.Y.Z] - YYYY-MM-DD`, with no empty `## [Unreleased]` left above it.
11
+ When the next change lands and there's no `## [Unreleased]` section, add
12
+ one at the top.
11
13
  - **Single source of truth — `pyproject.toml`**: canonical for dependencies,
12
14
  supported Python version, build config, tool settings. Don't restate any of
13
15
  these in prose; refer to `pyproject.toml`.
@@ -52,6 +54,13 @@
52
54
  on `x` or `y`, overrides its sort to match the tree's tip order, and
53
55
  hconcat's or vconcat's a tree panel on the matching side. Tree dictates
54
56
  tip ordering; plot follows.
57
+ - **Loaded specs go to `from_dict` unmodified.** Don't strip or rewrite
58
+ altair's annotations when loading a dict / JSON / HTML chart. Selection
59
+ params' `views` in particular are valid at the top level; removing them made
60
+ every compound chart with a selection fail to render ("Duplicate signal
61
+ name"). See `_load_chart`. Likewise, declare extra params by appending to
62
+ the combined chart's top-level `params`, never with `add_params`, which
63
+ recurses into sub-charts and raises on spec-loaded ones.
55
64
  - **Two surfaces, one config**: `tree_annotated_plot.plot()` and the `tree-annotated-plot`
56
65
  CLI both accept the same parameters via `PlotConfig`. They converge on
57
66
  `_build(tree, chart, config)` so they can never disagree.
@@ -78,7 +87,10 @@
78
87
  - **Eyeball examples after any rendering change.** There are no
79
88
  image-snapshot regression tests (vl-convert/font/platform variance
80
89
  makes them flaky), so visual regressions only get caught by
81
- looking. After any change to `_plot.py`, `_tree.py`, the example
90
+ looking. Tests may still render with `vl_convert.vegalite_to_svg`
91
+ to check that a spec renders at all, or to assert discrete SVG
92
+ attributes such as a label's `font-weight` / `fill`; those are
93
+ deterministic. After any change to `_plot.py`, `_tree.py`, the example
82
94
  scripts, or anything they touch: run `scripts/build_docs.sh`, open
83
95
  `site/examples.html`, and click through each example — both the
84
96
  embedded SVG and the interactive HTML link. Confirm tip-row
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tree-annotated-plot
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
5
5
  Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
6
6
  Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
@@ -140,6 +140,77 @@ CLI flags: `--connect-leader-to-label --strain-label-font-size 9
140
140
  --shift-tree-loc 60`. In Python:
141
141
  `connect_leader_to_label=True, strain_label_font_size=9, shift_tree_loc=60`.
142
142
 
143
+ ### Optional: emphasize some strains' labels
144
+
145
+ With label connection on, you can also style a subset of the labels, for
146
+ example so the vaccine strains are easy to pick out. Pass
147
+ `strain_label_styles`, a list of rules. Each rule names some `strains`
148
+ (tree tip identifiers, i.e. `tree_strain_field` values) and a `style` built
149
+ from Vega-Lite text properties: `fill`, `opacity`, `fontWeight` and
150
+ `fontStyle`. A rule can also take a `when` condition, usually the name of a
151
+ checkbox parameter, so the style applies only while the box is ticked.
152
+ Where rules overlap, later rules win.
153
+
154
+ The example below draws the labels of the H3N2 recent vaccine strains in
155
+ bold red, with a checkbox that turns the emphasis off and on:
156
+
157
+ ![H3N2 with vaccine-strain labels in bold red](images/h3n2_combined_label_styles.svg)
158
+
159
+ [Open the interactive chart in a new tab →](charts/h3n2_combined_label_styles.html){target="_blank"}
160
+
161
+ From the CLI, a `when` parameter has to be declared in the saved chart, so
162
+ add it before saving:
163
+
164
+ ```python
165
+ chart = chart.add_params(
166
+ alt.param(
167
+ name="vaccine_strains",
168
+ value=True,
169
+ bind=alt.binding_checkbox(name="highlight vaccine strains "),
170
+ )
171
+ )
172
+ chart.save("titers.json")
173
+ ```
174
+
175
+ then pass the rules as a JSON array, alongside the label-connection flags
176
+ above:
177
+
178
+ ```bash
179
+ --connect-leader-to-label --strain-label-styles \
180
+ '[{"strains": ["J.2.4", "J.2:S145N"],
181
+ "style": {"fill": "#d62728", "fontWeight": "bold"},
182
+ "when": "vaccine_strains"}]'
183
+ ```
184
+
185
+ In Python, pass the parameter object itself. If your chart doesn't already
186
+ declare it, it is declared on the returned chart for you:
187
+
188
+ ```python
189
+ vaccine_strains = alt.param(
190
+ name="vaccine_strains",
191
+ value=True,
192
+ bind=alt.binding_checkbox(name="highlight vaccine strains "),
193
+ )
194
+ tree_annotated_plot.plot(
195
+ ...,
196
+ connect_leader_to_label=True,
197
+ strain_label_styles=[
198
+ {
199
+ "strains": ["J.2.4", "J.2:S145N"],
200
+ "style": {"fill": "#d62728", "fontWeight": "bold"},
201
+ "when": vaccine_strains,
202
+ }
203
+ ],
204
+ )
205
+ ```
206
+
207
+ Leave out `when` for a style that is always on. `when` can be any Vega
208
+ expression, so other inputs work too. For example, with a radio button
209
+ `alt.param(name="emphasis", value="cell", bind=alt.binding_radio(options=["cell", "egg"]))`
210
+ declared on the chart, one rule with `"when": "emphasis == 'cell'"` and
211
+ another with `"when": "emphasis == 'egg'"` switch the emphasis between two
212
+ groups of strains.
213
+
143
214
  ### Color the tree
144
215
 
145
216
  Pass `color_tree_by` to color the tree's branches and tip circles by
@@ -131,6 +131,17 @@ def strain_plot_order(viruses_subset: pd.DataFrame) -> list[str]:
131
131
  return sorted_df["virus"].tolist()
132
132
 
133
133
 
134
+ def vaccine_axis_labels(viruses: pd.DataFrame, subtype: str) -> list[str]:
135
+ """Axis labels of the `RECENT_VACCINE_STRAINS` in `subtype`, in table order.
136
+
137
+ These are the strain-axis values the chart (and so the tree's tips) use for
138
+ the vaccine strains, e.g. for `strain_label_styles`."""
139
+ is_vaccine = (viruses["subtype"] == subtype) & viruses["virus"].isin(
140
+ RECENT_VACCINE_STRAINS
141
+ )
142
+ return viruses.loc[is_vaccine, "axis_label"].tolist()
143
+
144
+
134
145
  def make_chart(
135
146
  subtype: str,
136
147
  chart_type: str,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tree-annotated-plot"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -25,6 +25,8 @@ import sys
25
25
  from pathlib import Path
26
26
  from types import ModuleType
27
27
 
28
+ import altair as alt
29
+
28
30
  import tree_annotated_plot
29
31
 
30
32
  REPO = Path(__file__).resolve().parent.parent
@@ -163,6 +165,47 @@ def _render_kikawa() -> None:
163
165
  )
164
166
  _save_pair(out, "h3n2_combined_label_connect")
165
167
 
168
+ # The same label-connect H3N2 chart, with the recent vaccine strains'
169
+ # labels drawn in bold red while a checkbox is ticked: `strain_label_styles`
170
+ # with a `when` param. The checkbox starts ticked so the static SVG shows
171
+ # the emphasis.
172
+ h3n2_chart_styles = builder.make_chart(
173
+ subtype="H3N2",
174
+ chart_type="iqr",
175
+ titers=titers,
176
+ viruses=viruses,
177
+ metadata=metadata,
178
+ all_cohorts=all_cohorts,
179
+ )
180
+ vaccine_strains = alt.param(
181
+ name="vaccine_strains",
182
+ value=True,
183
+ bind=alt.binding_checkbox(name="highlight vaccine strains "),
184
+ )
185
+ out = tree_annotated_plot.plot(
186
+ DATA_DIR / "flu-seqneut-2025to2026_H3N2.json",
187
+ h3n2_chart_styles,
188
+ chart_strain_field="axis_label",
189
+ tree_strain_field="derived_haplotype",
190
+ branch_length="div",
191
+ tree_size=140,
192
+ scale_bar=True,
193
+ branch_length_units="substitutions",
194
+ connect_leader_to_label=True,
195
+ strain_label_font_size=9,
196
+ shift_tree_loc=60,
197
+ color_tree_by="subclade",
198
+ tree_color_legend_format={"orient": "left"},
199
+ strain_label_styles=[
200
+ {
201
+ "strains": builder.vaccine_axis_labels(viruses, "H3N2"),
202
+ "style": {"fill": "#d62728", "fontWeight": "bold"},
203
+ "when": vaccine_strains,
204
+ }
205
+ ],
206
+ )
207
+ _save_pair(out, "h3n2_combined_label_styles")
208
+
166
209
  # H3N2 once more, colored by genotype at HA1 site 158: same chart and
167
210
  # default layout as `h3n2_combined`, with `color_tree_by` switched to
168
211
  # the genotype form. Site 158 has two mutations (N158K, N158D) in the
@@ -161,6 +161,28 @@ class PlotConfig:
161
161
  "when `connect_leader_to_label` is on.",
162
162
  ] = "normal"
163
163
 
164
+ strain_label_styles: Annotated[
165
+ list[dict[str, Any]] | None,
166
+ "Style a subset of the strain labels rendered in the tree panel; "
167
+ "requires `connect_leader_to_label`. A list of rules, each a dict "
168
+ 'with keys "strains" (a list of tip identifiers, i.e. '
169
+ '`tree_strain_field` values), "style" and, optionally, "when". '
170
+ '"style" holds Vega-Lite text-mark properties: "fill" (a color), '
171
+ '"opacity" (0 to 1), "fontWeight" ("normal", "bold", or 100-900) '
172
+ 'and "fontStyle" ("normal" or "italic"). "when" is a Vega '
173
+ "expression that turns the rule on while it is truthy, usually just "
174
+ "the name of a parameter declared in the chart (e.g. one bound to a "
175
+ "checkbox), but any expression works (e.g. \"emphasis == 'cell'\" "
176
+ 'for a radio button). Without "when" the style always applies. '
177
+ "Where rules overlap, later rules win property by property; "
178
+ "properties no rule sets keep `strain_label_font_weight` and the "
179
+ "default black, upright text. A strain that isn't a tree tip is an "
180
+ "error; strains dropped by `prune_tree_to_chart` are skipped. CLI "
181
+ "form: a JSON array (quote the whole argument), e.g. "
182
+ '\'[{"strains": ["K"], "style": {"fill": "#d62728", '
183
+ '"fontWeight": "bold"}, "when": "vaccine_strains"}]\'.',
184
+ ] = None
185
+
164
186
  shift_tree_loc: Annotated[
165
187
  int,
166
188
  "Pixels by which to shift the tree toward (positive) or away from "
@@ -225,10 +247,25 @@ class PlotConfig:
225
247
 
226
248
 
227
249
  # Sidecar for Python-docstring-only prose, keyed by PlotConfig field name.
228
- # Empty by default — add an entry when a field's docstring entry needs more
229
- # than the CLI `--help` text covers. The extras are appended to the field's
230
- # description in `tree_annotated_plot.plot.__doc__`, never in CLI --help.
231
- PARAM_DOC_EXTRAS: dict[str, str] = {}
250
+ # Add an entry when a field's docstring entry needs more than the CLI
251
+ # `--help` text covers. The extras are appended to the field's description in
252
+ # `tree_annotated_plot.plot.__doc__`, never in CLI --help.
253
+ PARAM_DOC_EXTRAS: dict[str, str] = {
254
+ "strain_label_styles": (
255
+ 'In Python, "when" may also be an `altair.Parameter`, e.g. '
256
+ '`alt.param(name="vaccine_strains", value=True, '
257
+ "bind=alt.binding_checkbox())`, which is declared on the returned "
258
+ "chart if your chart doesn't already declare it; or an altair "
259
+ 'parameter expression such as `emphasis == "cell"`. Only a bare '
260
+ "`altair.Parameter` is declared for you: parameters that an "
261
+ "expression refers to must be declared on the chart, and an "
262
+ "expression other than a bare name isn't checked until the chart "
263
+ "renders. A bare name or parameter must be a variable parameter: "
264
+ "selection parameters are rejected, since the label data doesn't "
265
+ "carry the fields a selection tests. Any iterable of strings (e.g. "
266
+ 'a pandas Series) works for "strains".'
267
+ ),
268
+ }
232
269
 
233
270
 
234
271
  # Descriptions for the three data-input parameters. These can't live on
@@ -0,0 +1,349 @@
1
+ """Per-strain styling of the tree-panel strain labels (`strain_label_styles`).
2
+
3
+ Public entry points: :func:`resolve_rules` validates and normalizes the
4
+ user's rules, and :func:`mark_props` turns them into label mark properties.
5
+
6
+ Each rule names some strains, a style (a subset of Vega-Lite text-mark
7
+ properties) and, optionally, a `when` condition. Every property a rule sets
8
+ becomes one `alt.ExprRef` on the existing label mark(s), evaluated per label:
9
+ Vega-Lite compiles a mark-level ``{"expr": ...}`` into the Vega encode block,
10
+ where ``datum`` is the label's row. That is why a property with no encoding
11
+ channel (``fontWeight``, ``fontStyle``) can still vary by strain without
12
+ duplicating the label layers. Which rules a label belongs to is carried in
13
+ one boolean column per rule (:func:`membership_column`) on the label data.
14
+
15
+ ``fontSize`` is deliberately not a style key: the label strip's width is
16
+ estimated from `strain_label_font_size`, so a per-strain size would need a
17
+ different layout calculation.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import math
24
+ import re
25
+ from collections.abc import Iterable, Mapping
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ import altair as alt
30
+ from altair.expr.core import Expression
31
+
32
+ # Allowed `style` keys, in the order their expressions are emitted.
33
+ STYLE_KEYS = ("fill", "opacity", "fontWeight", "fontStyle")
34
+
35
+ # Style keys that change the glyph outline. The white halo layer drawn under
36
+ # each label has to follow these so it keeps masking the leader exactly where
37
+ # the text is; `fill` and `opacity` apply to the visible text only.
38
+ _GLYPH_KEYS = frozenset({"fontWeight", "fontStyle"})
39
+
40
+ _RULE_KEYS = frozenset({"strains", "style", "when"})
41
+
42
+ _FONT_WEIGHTS = ("normal", "bold")
43
+ _NUMERIC_FONT_WEIGHTS = tuple(range(100, 1000, 100))
44
+ _FONT_STYLES = ("normal", "italic")
45
+
46
+ # Hints for style keys a user is likely to reach for that we don't accept.
47
+ _STYLE_KEY_HINTS = {
48
+ "color": 'use "fill" to set the label color',
49
+ "fontSize": (
50
+ "per-strain font sizes are not supported because they change the "
51
+ "label-strip layout; use `strain_label_font_size` for all labels"
52
+ ),
53
+ }
54
+
55
+ # A bare Vega expression identifier, i.e. what a param name looks like.
56
+ _IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class LabelStyleRule:
61
+ """One validated `strain_label_styles` rule.
62
+
63
+ `strains` holds only the strains drawn in the tree (pruned ones are
64
+ dropped); `when` is a Vega expression string, or None for always-on.
65
+ """
66
+
67
+ strains: frozenset[str]
68
+ style: dict[str, Any]
69
+ when: str | None
70
+
71
+
72
+ def membership_column(index: int) -> str:
73
+ """Name of the boolean label-data column marking rule `index`'s strains."""
74
+ return f"_tap_label_style_{index}"
75
+
76
+
77
+ def resolve_rules(
78
+ raw: Any,
79
+ *,
80
+ input_tip_names: Iterable[str],
81
+ drawn_tip_names: Iterable[str],
82
+ chart_spec: dict,
83
+ tree_strain_field: str,
84
+ ) -> tuple[list[LabelStyleRule], list[alt.Parameter]]:
85
+ """Validate `strain_label_styles` and return ``(rules, params_to_declare)``.
86
+
87
+ `input_tip_names` are the tree's tips before `prune_tree_to_chart`; a
88
+ strain outside them is an error. `drawn_tip_names` are the tips left
89
+ after pruning; strains pruned away are silently dropped from their rule.
90
+ `params_to_declare` lists the `alt.Parameter` objects passed as `when`
91
+ that the chart doesn't already declare, deduplicated by name.
92
+ """
93
+ if raw is None:
94
+ return [], []
95
+ if isinstance(raw, (str, bytes, Mapping)) or not isinstance(raw, Iterable):
96
+ raise ValueError(
97
+ "strain_label_styles must be a list of rule dicts, each with "
98
+ f'"strains" and "style" keys; got {type(raw).__name__}.'
99
+ )
100
+ raw_rules = list(raw)
101
+ if not raw_rules:
102
+ return [], []
103
+
104
+ input_tips = set(input_tip_names)
105
+ drawn_tips = set(drawn_tip_names)
106
+ declared = _declared_params(chart_spec)
107
+ rule_params: dict[str, alt.Parameter] = {}
108
+ for rule in raw_rules:
109
+ when = rule.get("when") if isinstance(rule, Mapping) else None
110
+ if isinstance(when, alt.Parameter):
111
+ rule_params.setdefault(_param_name(when), when)
112
+
113
+ rules: list[LabelStyleRule] = []
114
+ for i, rule in enumerate(raw_rules):
115
+ if not isinstance(rule, Mapping):
116
+ raise ValueError(
117
+ f"strain_label_styles[{i}] must be a dict with "
118
+ f'"strains" and "style" keys; got {type(rule).__name__}.'
119
+ )
120
+ unknown_keys = set(rule) - _RULE_KEYS
121
+ if unknown_keys:
122
+ raise ValueError(
123
+ f"strain_label_styles[{i}] has unknown key(s) "
124
+ f"{sorted(unknown_keys)}; allowed keys are "
125
+ f"{sorted(_RULE_KEYS)}."
126
+ )
127
+ for required in ("strains", "style"):
128
+ if required not in rule:
129
+ raise ValueError(
130
+ f"strain_label_styles[{i}] is missing the required "
131
+ f"{required!r} key."
132
+ )
133
+ rules.append(
134
+ LabelStyleRule(
135
+ strains=_resolve_strains(
136
+ i,
137
+ rule["strains"],
138
+ input_tips=input_tips,
139
+ drawn_tips=drawn_tips,
140
+ tree_strain_field=tree_strain_field,
141
+ ),
142
+ style=_validate_style(i, rule["style"]),
143
+ when=_resolve_when(
144
+ i,
145
+ rule.get("when"),
146
+ declared=declared,
147
+ rule_param_names=set(rule_params),
148
+ ),
149
+ )
150
+ )
151
+
152
+ to_declare = [p for name, p in rule_params.items() if name not in declared]
153
+ return rules, to_declare
154
+
155
+
156
+ def mark_props(
157
+ rules: list[LabelStyleRule], *, default_font_weight: str
158
+ ) -> tuple[dict[str, alt.ExprRef], dict[str, alt.ExprRef]]:
159
+ """Return ``(halo_props, text_props)``: mark properties for the label layers.
160
+
161
+ Only properties that some rule sets get an expression; the rest keep the
162
+ marks' static settings. Each expression is a chain of ternaries built in
163
+ rule order, so the last rule's test is evaluated first and later rules
164
+ win where strains overlap. The fallback is the label's unstyled value:
165
+ `default_font_weight` for ``fontWeight``, and Vega-Lite's text defaults
166
+ (black, fully opaque, upright) for the others.
167
+ """
168
+ defaults = {
169
+ "fill": "black",
170
+ "opacity": 1,
171
+ "fontWeight": default_font_weight,
172
+ "fontStyle": "normal",
173
+ }
174
+ text_props: dict[str, alt.ExprRef] = {}
175
+ for key in STYLE_KEYS:
176
+ expr = json.dumps(defaults[key])
177
+ styled = False
178
+ for i, rule in enumerate(rules):
179
+ if key not in rule.style:
180
+ continue
181
+ test = f"datum[{json.dumps(membership_column(i))}]"
182
+ if rule.when is not None:
183
+ test = f"({rule.when}) && {test}"
184
+ expr = f"{test} ? {json.dumps(rule.style[key])} : ({expr})"
185
+ styled = True
186
+ if styled:
187
+ text_props[key] = alt.ExprRef(expr)
188
+ halo_props = {k: v for k, v in text_props.items() if k in _GLYPH_KEYS}
189
+ return halo_props, text_props
190
+
191
+
192
+ def _resolve_strains(
193
+ index: int,
194
+ strains: Any,
195
+ *,
196
+ input_tips: set[str],
197
+ drawn_tips: set[str],
198
+ tree_strain_field: str,
199
+ ) -> frozenset[str]:
200
+ if isinstance(strains, (str, bytes, Mapping)) or not isinstance(strains, Iterable):
201
+ raise ValueError(
202
+ f'strain_label_styles[{index}]["strains"] must be a list of '
203
+ f"strain names; got {type(strains).__name__} {strains!r}."
204
+ )
205
+ strains = list(strains)
206
+ if not strains:
207
+ raise ValueError(
208
+ f'strain_label_styles[{index}]["strains"] is empty; a rule must '
209
+ "name at least one strain."
210
+ )
211
+ non_str = [s for s in strains if not isinstance(s, str)]
212
+ if non_str:
213
+ raise ValueError(
214
+ f'strain_label_styles[{index}]["strains"] must contain strings; '
215
+ f"got {non_str[:5]!r}."
216
+ )
217
+ unknown = sorted(set(strains) - input_tips)
218
+ if unknown:
219
+ raise ValueError(
220
+ f'strain_label_styles[{index}]["strains"] has {len(unknown)} '
221
+ "strain(s) that are not tips of the tree under "
222
+ f"tree_strain_field={tree_strain_field!r}.\n"
223
+ f"Sample unknown strains: {unknown[:5]}\n"
224
+ f"Sample tree tips: {sorted(input_tips)[:5]}"
225
+ )
226
+ return frozenset(s for s in strains if s in drawn_tips)
227
+
228
+
229
+ def _validate_style(index: int, style: Any) -> dict[str, Any]:
230
+ where = f'strain_label_styles[{index}]["style"]'
231
+ if not isinstance(style, Mapping) or not style:
232
+ raise ValueError(
233
+ f"{where} must be a non-empty dict of label properties (any of "
234
+ f"{list(STYLE_KEYS)}); got {style!r}."
235
+ )
236
+ for key, value in style.items():
237
+ if key not in STYLE_KEYS:
238
+ hint = _STYLE_KEY_HINTS.get(key)
239
+ raise ValueError(
240
+ f"{where} has unsupported key {key!r}; allowed keys are "
241
+ f"{list(STYLE_KEYS)}." + (f" Hint: {hint}." if hint else "")
242
+ )
243
+ if key == "fill":
244
+ ok = isinstance(value, str) and bool(value.strip())
245
+ expected = "a non-empty color string"
246
+ elif key == "opacity":
247
+ ok = (
248
+ isinstance(value, (int, float))
249
+ and not isinstance(value, bool)
250
+ and math.isfinite(value)
251
+ and 0 <= value <= 1
252
+ )
253
+ expected = "a number from 0 to 1"
254
+ elif key == "fontWeight":
255
+ ok = value in _FONT_WEIGHTS or (
256
+ isinstance(value, int)
257
+ and not isinstance(value, bool)
258
+ and value in _NUMERIC_FONT_WEIGHTS
259
+ )
260
+ expected = f"one of {list(_FONT_WEIGHTS)} or 100-900 in steps of 100"
261
+ else: # fontStyle
262
+ ok = value in _FONT_STYLES
263
+ expected = f"one of {list(_FONT_STYLES)}"
264
+ if not ok:
265
+ raise ValueError(f"{where}[{key!r}] must be {expected}; got {value!r}.")
266
+ return dict(style)
267
+
268
+
269
+ def _resolve_when(
270
+ index: int,
271
+ when: Any,
272
+ *,
273
+ declared: dict[str, dict],
274
+ rule_param_names: set[str],
275
+ ) -> str | None:
276
+ """Return `when` as a Vega expression string (None means always on)."""
277
+ where = f'strain_label_styles[{index}]["when"]'
278
+ if when is None:
279
+ return None
280
+ if isinstance(when, alt.Parameter):
281
+ if when.param_type != "variable":
282
+ raise ValueError(
283
+ f"{where} is a selection parameter ({_param_name(when)!r}); "
284
+ "only variable parameters (alt.param(...), e.g. bound to a "
285
+ "checkbox) are supported, since the label data doesn't carry "
286
+ "the fields a selection tests."
287
+ )
288
+ return _param_name(when)
289
+ if isinstance(when, alt.ParameterExpression):
290
+ expr = when.to_dict().get("expr")
291
+ elif isinstance(when, Expression):
292
+ expr = when.to_dict()
293
+ elif isinstance(when, str):
294
+ expr = when
295
+ else:
296
+ raise ValueError(
297
+ f"{where} must be a param name or Vega expression string, an "
298
+ "alt.Parameter, or an altair parameter expression; got "
299
+ f"{type(when).__name__}."
300
+ )
301
+ if not isinstance(expr, str) or not expr.strip():
302
+ raise ValueError(f"{where} must not be empty; got {when!r}.")
303
+ expr = expr.strip()
304
+ if _IDENTIFIER.fullmatch(expr) and expr not in rule_param_names:
305
+ if expr not in declared:
306
+ raise ValueError(
307
+ f"{where}={expr!r} names no parameter declared in the chart. "
308
+ f"Declared parameters: {sorted(declared) or 'none'}. Declare "
309
+ "it on the chart (e.g. chart.add_params(alt.param(name="
310
+ f"{expr!r}, value=True, bind=alt.binding_checkbox()))) before "
311
+ "saving it, or, in Python, pass the alt.Parameter itself."
312
+ )
313
+ if "select" in declared[expr]:
314
+ raise ValueError(
315
+ f"{where}={expr!r} is a selection parameter; only variable "
316
+ "parameters (e.g. bound to a checkbox) are supported, since "
317
+ "the label data doesn't carry the fields a selection tests."
318
+ )
319
+ return expr
320
+
321
+
322
+ def _declared_params(spec: Any) -> dict[str, dict]:
323
+ """Map every param name declared anywhere in a chart spec to its definition."""
324
+ found: dict[str, dict] = {}
325
+
326
+ def walk(node: Any) -> None:
327
+ if isinstance(node, dict):
328
+ params = node.get("params")
329
+ if isinstance(params, list):
330
+ for p in params:
331
+ if isinstance(p, dict) and isinstance(p.get("name"), str):
332
+ found.setdefault(p["name"], p)
333
+ for k, v in node.items():
334
+ if k in ("data", "datasets"):
335
+ continue
336
+ walk(v)
337
+ elif isinstance(node, list):
338
+ for item in node:
339
+ walk(item)
340
+
341
+ walk(spec)
342
+ return found
343
+
344
+
345
+ def _param_name(param: alt.Parameter) -> str:
346
+ name = param.name
347
+ if hasattr(name, "to_dict"):
348
+ name = name.to_dict()
349
+ return str(name)