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.
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/CHANGELOG.md +36 -1
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/CLAUDE.md +14 -2
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/PKG-INFO +1 -1
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/examples.md +71 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/flu-seqneut-2025to2026_titer_charts.py +11 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/pyproject.toml +1 -1
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/generate_docs_assets.py +43 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_config.py +41 -4
- tree_annotated_plot-0.5.0/src/tree_annotated_plot/_label_style.py +349 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_plot.py +81 -63
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/cli.py +62 -12
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_chart_loader.py +108 -0
- tree_annotated_plot-0.5.0/tests/test_strain_label_styles.py +565 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/docs.yml +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/release.yml +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.github/workflows/tests.yml +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/.gitignore +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/LICENSE +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/README.md +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/cli.md +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/index.md +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/docs/python-api.md +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/fetch_auspice_data.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/examples/synthetic_example.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/mkdocs.yml +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/build_docs.sh +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/scripts/check.sh +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/__init__.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_color.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/src/tree_annotated_plot/_tree.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_appearance_tuning.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_branch_length.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_cli.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_color_tree.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_examples_run.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_introspection.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_label_connection.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_line_order.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_plot.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_plot_config.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_real_data.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_reconciliation.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_scale_bar.py +0 -0
- {tree_annotated_plot-0.4.0 → tree_annotated_plot-0.5.0}/tests/test_style.py +0 -0
- {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
|
-
## [
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+

|
|
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,
|
|
@@ -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
|
-
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
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)
|