tree-annotated-plot 0.2.2__tar.gz → 0.4.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.2.2 → tree_annotated_plot-0.4.0}/CHANGELOG.md +46 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/PKG-INFO +7 -5
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/README.md +1 -1
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/docs/index.md +2 -2
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/pyproject.toml +14 -5
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_config.py +20 -5
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_plot.py +137 -20
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/cli.py +1 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_appearance_tuning.py +27 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_cli.py +9 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_label_connection.py +48 -3
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_reconciliation.py +94 -3
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_tree_location.py +52 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/.github/workflows/docs.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/.github/workflows/release.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/.github/workflows/tests.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/.gitignore +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/CLAUDE.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/LICENSE +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/docs/cli.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/docs/examples.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/docs/python-api.md +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/examples/fetch_auspice_data.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/examples/synthetic_example.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/mkdocs.yml +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/scripts/build_docs.sh +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/scripts/check.sh +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/scripts/generate_docs_assets.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/__init__.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_color.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_tree.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_branch_length.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_chart_loader.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_color_tree.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_examples_run.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_introspection.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_line_order.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_plot.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_plot_config.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_real_data.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_scale_bar.py +0 -0
- {tree_annotated_plot-0.2.2 → tree_annotated_plot-0.4.0}/tests/test_style.py +0 -0
|
@@ -5,6 +5,52 @@ 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]
|
|
9
|
+
|
|
10
|
+
## [0.4.0] - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `spacing` (default `0`): pixels of blank space between the tree panel and
|
|
15
|
+
the chart panel. `0` keeps the two butted together, as before. Distinct from
|
|
16
|
+
`shift_tree_loc`, which moves the tree within its own panel by resizing the
|
|
17
|
+
label strip. CLI form: `--spacing`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Pin `ruff` to `>=0.16,<0.17` and state the lint rules explicitly with
|
|
22
|
+
`[tool.ruff.lint] select`. Ruff's *default* rule set widened in 0.16, which
|
|
23
|
+
turned CI red on unchanged code; the selection is now a project decision
|
|
24
|
+
rather than whatever the installed ruff defaults to. Development-only, no
|
|
25
|
+
effect on the package.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- With `connect_leader_to_label` on, strain labels were anchored exactly on the
|
|
30
|
+
tree panel's chart-facing edge, so the text ran up against the chart's frame
|
|
31
|
+
and the frame line became hard to read. The pad that the label strip already
|
|
32
|
+
reserved is now applied between the text and the chart, and the leader lines
|
|
33
|
+
stop where the text begins rather than continuing to the panel edge. The gap
|
|
34
|
+
is `max(3, strain_label_font_size * 0.2)` px, and the tree panel grows by that
|
|
35
|
+
much, so a hand-tuned `shift_tree_loc` may want the same adjustment.
|
|
36
|
+
|
|
37
|
+
## [0.3.0] - 2026-05-19
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `prune_chart_to_tree` (default `False`): when on, chart rows whose
|
|
42
|
+
`chart_strain_field` value isn't a tree tip are filtered out before
|
|
43
|
+
drawing. CLI form:
|
|
44
|
+
`--prune-chart-to-tree / --no-prune-chart-to-tree`.
|
|
45
|
+
([#6](https://github.com/jbloomlab/tree-annotated-plot/issues/6))
|
|
46
|
+
- `-h` is accepted as a short alias for `--help` on the
|
|
47
|
+
`tree-annotated-plot` CLI.
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- Minimum supported Python lowered from 3.13 to 3.11. CI still
|
|
52
|
+
runs on 3.13.
|
|
53
|
+
|
|
8
54
|
## [0.2.2] - 2026-05-09
|
|
9
55
|
|
|
10
56
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: tree-annotated-plot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -30,10 +30,12 @@ License-File: LICENSE
|
|
|
30
30
|
Classifier: License :: OSI Approved :: MIT License
|
|
31
31
|
Classifier: Operating System :: OS Independent
|
|
32
32
|
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
33
35
|
Classifier: Programming Language :: Python :: 3.13
|
|
34
36
|
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
35
37
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
36
|
-
Requires-Python: >=3.
|
|
38
|
+
Requires-Python: >=3.11
|
|
37
39
|
Requires-Dist: altair<7,>=6.0
|
|
38
40
|
Requires-Dist: click>=8.0
|
|
39
41
|
Requires-Dist: pandas>=2.0
|
|
@@ -41,7 +43,7 @@ Requires-Dist: vl-convert-python>=1.9
|
|
|
41
43
|
Provides-Extra: dev
|
|
42
44
|
Requires-Dist: black>=24; extra == 'dev'
|
|
43
45
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
44
|
-
Requires-Dist: ruff
|
|
46
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
|
|
45
47
|
Provides-Extra: docs
|
|
46
48
|
Requires-Dist: mkdocs-click>=0.8; extra == 'docs'
|
|
47
49
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
@@ -66,7 +68,7 @@ See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.
|
|
|
66
68
|
|
|
67
69
|
## Installation
|
|
68
70
|
|
|
69
|
-
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.
|
|
71
|
+
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.11+.
|
|
70
72
|
|
|
71
73
|
```bash
|
|
72
74
|
pip install tree-annotated-plot
|
|
@@ -15,7 +15,7 @@ See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.
|
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
18
|
-
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.
|
|
18
|
+
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/). Requires Python 3.11+.
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
pip install tree-annotated-plot
|
|
@@ -27,7 +27,7 @@ sit on by default (left for y-axis, bottom for x-axis).
|
|
|
27
27
|
## Installation
|
|
28
28
|
|
|
29
29
|
Released on [PyPI](https://pypi.org/project/tree-annotated-plot/).
|
|
30
|
-
Requires Python 3.
|
|
30
|
+
Requires Python 3.11+.
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
pip install tree-annotated-plot
|
|
@@ -36,7 +36,7 @@ pip install tree-annotated-plot
|
|
|
36
36
|
To pin a specific version:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
pip install tree-annotated-plot==0.0
|
|
39
|
+
pip install tree-annotated-plot==0.3.0
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
To install the bleeding edge directly from the
|
|
@@ -4,15 +4,17 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tree-annotated-plot"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { file = "LICENSE" }
|
|
12
12
|
authors = [{ name = "Jesse Bloom", email = "jbloom@fredhutch.org" }]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"License :: OSI Approved :: MIT License",
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
16
18
|
"Programming Language :: Python :: 3.13",
|
|
17
19
|
"Operating System :: OS Independent",
|
|
18
20
|
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
@@ -34,7 +36,7 @@ Issues = "https://github.com/jbloomlab/tree-annotated-plot/issues"
|
|
|
34
36
|
[project.optional-dependencies]
|
|
35
37
|
dev = [
|
|
36
38
|
"pytest>=8",
|
|
37
|
-
"ruff>=0.
|
|
39
|
+
"ruff>=0.16,<0.17",
|
|
38
40
|
"black>=24",
|
|
39
41
|
]
|
|
40
42
|
docs = [
|
|
@@ -52,7 +54,14 @@ packages = ["src/tree_annotated_plot"]
|
|
|
52
54
|
dev-mode-dirs = ["src"]
|
|
53
55
|
|
|
54
56
|
[tool.black]
|
|
55
|
-
target-version = ["
|
|
57
|
+
target-version = ["py311"]
|
|
56
58
|
|
|
57
59
|
[tool.ruff]
|
|
58
|
-
target-version = "
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
# Ruff's default rule set is not stable across releases -- 0.16 broadened it,
|
|
64
|
+
# which turned CI red on unchanged code. State the rules we lint against so a
|
|
65
|
+
# ruff upgrade changes them only when we say so. These are ruff's pre-0.16
|
|
66
|
+
# defaults, which is what this project has been checked against all along.
|
|
67
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -68,6 +68,15 @@ class PlotConfig:
|
|
|
68
68
|
"(x-encoded).",
|
|
69
69
|
] = None
|
|
70
70
|
|
|
71
|
+
spacing: Annotated[
|
|
72
|
+
int,
|
|
73
|
+
"Pixels of blank space between the tree panel and the chart panel. "
|
|
74
|
+
"Default 0, which butts the two together. Distinct from "
|
|
75
|
+
"`shift_tree_loc`, which moves the tree within its own panel by "
|
|
76
|
+
"resizing the label strip; `spacing` sets the gap between the panels "
|
|
77
|
+
"and applies whether or not `connect_leader_to_label` is on.",
|
|
78
|
+
] = 0
|
|
79
|
+
|
|
71
80
|
tree_line_width: Annotated[
|
|
72
81
|
float,
|
|
73
82
|
"Stroke width (px) for the tree's branch lines. Default 2.",
|
|
@@ -109,9 +118,14 @@ class PlotConfig:
|
|
|
109
118
|
"When off (default), tree tips not present in the chart's strain "
|
|
110
119
|
"set are a fatal error. When on, those tips (and any internal "
|
|
111
120
|
"nodes whose subtrees become empty) are dropped before drawing, "
|
|
112
|
-
"with single-child internals collapsed into their kept child.
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
"with single-child internals collapsed into their kept child.",
|
|
122
|
+
] = False
|
|
123
|
+
|
|
124
|
+
prune_chart_to_tree: Annotated[
|
|
125
|
+
bool,
|
|
126
|
+
"When off (default), chart strains not present in the tree are a "
|
|
127
|
+
"fatal error. When on, chart rows whose `chart_strain_field` "
|
|
128
|
+
"value isn't a tree tip are filtered out before drawing.",
|
|
115
129
|
] = False
|
|
116
130
|
|
|
117
131
|
strict_version: Annotated[
|
|
@@ -150,8 +164,9 @@ class PlotConfig:
|
|
|
150
164
|
shift_tree_loc: Annotated[
|
|
151
165
|
int,
|
|
152
166
|
"Pixels by which to shift the tree toward (positive) or away from "
|
|
153
|
-
"(negative) the chart
|
|
154
|
-
"connect_leader_to_label is off
|
|
167
|
+
"(negative) the chart, by resizing the label strip inside the tree "
|
|
168
|
+
"panel. Default 0. Has no effect when connect_leader_to_label is off; "
|
|
169
|
+
"to set the gap between the tree and chart panels use `spacing`.",
|
|
155
170
|
] = 0
|
|
156
171
|
|
|
157
172
|
color_tree_by: Annotated[
|
|
@@ -49,12 +49,14 @@ def plot(
|
|
|
49
49
|
branch_length: Literal["div", "num_date"],
|
|
50
50
|
tree_size: int = 100,
|
|
51
51
|
tree_location: TreeLocation | None = None,
|
|
52
|
+
spacing: int = 0,
|
|
52
53
|
tree_line_width: float = 2.0,
|
|
53
54
|
tree_node_size: float = 45,
|
|
54
55
|
leader_line_width: float = 1.0,
|
|
55
56
|
scale_bar: bool = False,
|
|
56
57
|
branch_length_units: str | None = None,
|
|
57
58
|
prune_tree_to_chart: bool = False,
|
|
59
|
+
prune_chart_to_tree: bool = False,
|
|
58
60
|
strict_version: bool = True,
|
|
59
61
|
connect_leader_to_label: bool = False,
|
|
60
62
|
strain_label_font_size: float = 10.0,
|
|
@@ -76,12 +78,14 @@ def plot(
|
|
|
76
78
|
branch_length=branch_length,
|
|
77
79
|
tree_size=tree_size,
|
|
78
80
|
tree_location=tree_location,
|
|
81
|
+
spacing=spacing,
|
|
79
82
|
tree_line_width=tree_line_width,
|
|
80
83
|
tree_node_size=tree_node_size,
|
|
81
84
|
leader_line_width=leader_line_width,
|
|
82
85
|
scale_bar=scale_bar,
|
|
83
86
|
branch_length_units=branch_length_units,
|
|
84
87
|
prune_tree_to_chart=prune_tree_to_chart,
|
|
88
|
+
prune_chart_to_tree=prune_chart_to_tree,
|
|
85
89
|
strict_version=strict_version,
|
|
86
90
|
connect_leader_to_label=connect_leader_to_label,
|
|
87
91
|
strain_label_font_size=strain_label_font_size,
|
|
@@ -168,12 +172,33 @@ def _build(
|
|
|
168
172
|
|
|
169
173
|
chart_strains = _extract_chart_strains(spec, axis_hits, config.chart_strain_field)
|
|
170
174
|
|
|
175
|
+
if config.prune_chart_to_tree and (set(chart_strains) - set(tip_names)):
|
|
176
|
+
_prune_chart_spec_to_strains(
|
|
177
|
+
spec,
|
|
178
|
+
chart_strain_field=config.chart_strain_field,
|
|
179
|
+
keep_strains=set(tip_names),
|
|
180
|
+
)
|
|
181
|
+
chart = alt.Chart.from_dict(spec)
|
|
182
|
+
chart_strains = _extract_chart_strains(
|
|
183
|
+
spec, axis_hits, config.chart_strain_field
|
|
184
|
+
)
|
|
185
|
+
if not chart_strains:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"prune_chart_to_tree=True dropped every chart row: no "
|
|
188
|
+
"chart strain matched any tree tip under "
|
|
189
|
+
f"chart_strain_field={config.chart_strain_field!r} / "
|
|
190
|
+
f"tree_strain_field={config.tree_strain_field!r}. Pruning "
|
|
191
|
+
"is meant for charts that bundle a superset of strains; "
|
|
192
|
+
"an empty intersection suggests a wrong field choice."
|
|
193
|
+
)
|
|
194
|
+
|
|
171
195
|
_reconcile_tips_and_strains(
|
|
172
196
|
tree_strains=tip_names,
|
|
173
197
|
chart_strains=chart_strains,
|
|
174
198
|
chart_strain_field=config.chart_strain_field,
|
|
175
199
|
tree_strain_field=config.tree_strain_field,
|
|
176
200
|
prune_tree_to_chart=config.prune_tree_to_chart,
|
|
201
|
+
prune_chart_to_tree=config.prune_chart_to_tree,
|
|
177
202
|
chart_spec=spec,
|
|
178
203
|
tree_source=tree,
|
|
179
204
|
)
|
|
@@ -258,7 +283,10 @@ def _build(
|
|
|
258
283
|
hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
|
|
259
284
|
|
|
260
285
|
combined = _concat_for_location(
|
|
261
|
-
tree_chart=tree_chart,
|
|
286
|
+
tree_chart=tree_chart,
|
|
287
|
+
user_chart=new_chart,
|
|
288
|
+
location=location,
|
|
289
|
+
spacing=config.spacing,
|
|
262
290
|
)
|
|
263
291
|
_apply_combined_config(combined, hoisted_config)
|
|
264
292
|
for k, v in hoisted_other.items():
|
|
@@ -297,6 +325,7 @@ def _concat_for_location(
|
|
|
297
325
|
tree_chart: alt.TopLevelMixin,
|
|
298
326
|
user_chart: alt.TopLevelMixin,
|
|
299
327
|
location: TreeLocation,
|
|
328
|
+
spacing: int,
|
|
300
329
|
) -> alt.HConcatChart | alt.VConcatChart:
|
|
301
330
|
"""Concat tree and chart in the order implied by the tree's location.
|
|
302
331
|
|
|
@@ -310,19 +339,19 @@ def _concat_for_location(
|
|
|
310
339
|
user-chart marks whose color values aren't in the tree's domain.
|
|
311
340
|
"""
|
|
312
341
|
if location == "left":
|
|
313
|
-
return alt.hconcat(tree_chart, user_chart, spacing=
|
|
342
|
+
return alt.hconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
|
|
314
343
|
y="independent", color="independent"
|
|
315
344
|
)
|
|
316
345
|
if location == "right":
|
|
317
|
-
return alt.hconcat(user_chart, tree_chart, spacing=
|
|
346
|
+
return alt.hconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
|
|
318
347
|
y="independent", color="independent"
|
|
319
348
|
)
|
|
320
349
|
if location == "top":
|
|
321
|
-
return alt.vconcat(tree_chart, user_chart, spacing=
|
|
350
|
+
return alt.vconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
|
|
322
351
|
x="independent", color="independent"
|
|
323
352
|
)
|
|
324
353
|
if location == "bottom":
|
|
325
|
-
return alt.vconcat(user_chart, tree_chart, spacing=
|
|
354
|
+
return alt.vconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
|
|
326
355
|
x="independent", color="independent"
|
|
327
356
|
)
|
|
328
357
|
raise ValueError(f"unreachable: tree_location={location!r}")
|
|
@@ -641,6 +670,70 @@ def _extract_chart_strains(
|
|
|
641
670
|
return _extract_field_values_from_spec_data(spec, chart_strain_field)
|
|
642
671
|
|
|
643
672
|
|
|
673
|
+
def _prune_chart_spec_to_strains(
|
|
674
|
+
spec: dict, *, chart_strain_field: str, keep_strains: set[str]
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Filter a Vega-Lite spec in place to drop rows outside `keep_strains`.
|
|
677
|
+
|
|
678
|
+
Mutates three kinds of structure:
|
|
679
|
+
- top-level `datasets` entries (each a list of row-dicts).
|
|
680
|
+
- inline `data.values` lists anywhere in the spec tree.
|
|
681
|
+
- explicit `sort` lists on encoding channels bound to
|
|
682
|
+
`chart_strain_field` (any other `sort` is left alone).
|
|
683
|
+
|
|
684
|
+
Rows that don't carry `chart_strain_field` at all are preserved
|
|
685
|
+
(we have no signal to drop them). URL-backed data raises — we
|
|
686
|
+
can't fetch + filter at plot time, mirroring `_extract_chart_strains`.
|
|
687
|
+
"""
|
|
688
|
+
|
|
689
|
+
def row_kept(row: Any) -> bool:
|
|
690
|
+
if not isinstance(row, dict):
|
|
691
|
+
return True
|
|
692
|
+
if chart_strain_field not in row:
|
|
693
|
+
return True
|
|
694
|
+
return row[chart_strain_field] in keep_strains
|
|
695
|
+
|
|
696
|
+
datasets = spec.get("datasets") if isinstance(spec, dict) else None
|
|
697
|
+
if isinstance(datasets, dict):
|
|
698
|
+
for name, rows in list(datasets.items()):
|
|
699
|
+
if isinstance(rows, list):
|
|
700
|
+
datasets[name] = [row for row in rows if row_kept(row)]
|
|
701
|
+
|
|
702
|
+
def walk(node: Any) -> None:
|
|
703
|
+
if isinstance(node, dict):
|
|
704
|
+
data = node.get("data")
|
|
705
|
+
if isinstance(data, dict):
|
|
706
|
+
if "url" in data:
|
|
707
|
+
raise ValueError(
|
|
708
|
+
f"chart references data via URL ({data['url']!r}); "
|
|
709
|
+
"URL data is not supported, so prune_chart_to_tree "
|
|
710
|
+
"cannot filter it. Materialize the data inline "
|
|
711
|
+
"(via alt.Chart(df) with a pandas DataFrame) before "
|
|
712
|
+
"saving the chart."
|
|
713
|
+
)
|
|
714
|
+
if "values" in data and isinstance(data["values"], list):
|
|
715
|
+
data["values"] = [row for row in data["values"] if row_kept(row)]
|
|
716
|
+
encoding = node.get("encoding")
|
|
717
|
+
if isinstance(encoding, dict):
|
|
718
|
+
for channel in encoding.values():
|
|
719
|
+
if not isinstance(channel, dict):
|
|
720
|
+
continue
|
|
721
|
+
if channel.get("field") != chart_strain_field:
|
|
722
|
+
continue
|
|
723
|
+
sort = channel.get("sort")
|
|
724
|
+
if isinstance(sort, list):
|
|
725
|
+
channel["sort"] = [s for s in sort if s in keep_strains]
|
|
726
|
+
for k, v in node.items():
|
|
727
|
+
if k in ("data", "datasets"):
|
|
728
|
+
continue
|
|
729
|
+
walk(v)
|
|
730
|
+
elif isinstance(node, list):
|
|
731
|
+
for item in node:
|
|
732
|
+
walk(item)
|
|
733
|
+
|
|
734
|
+
walk(spec)
|
|
735
|
+
|
|
736
|
+
|
|
644
737
|
def _extract_field_values_from_spec_data(spec: dict, field: str) -> list[str]:
|
|
645
738
|
"""Walk spec for inline / named data and return distinct values of `field`.
|
|
646
739
|
|
|
@@ -708,13 +801,16 @@ def _reconcile_tips_and_strains(
|
|
|
708
801
|
chart_strain_field: str,
|
|
709
802
|
tree_strain_field: str,
|
|
710
803
|
prune_tree_to_chart: bool,
|
|
804
|
+
prune_chart_to_tree: bool,
|
|
711
805
|
chart_spec: dict,
|
|
712
806
|
tree_source: Any,
|
|
713
807
|
) -> None:
|
|
714
808
|
"""Verify tree strains and chart strains are reconcilable.
|
|
715
809
|
|
|
716
810
|
Three asymmetries:
|
|
717
|
-
- chart strains not in tree →
|
|
811
|
+
- chart strains not in tree → fatal unless `prune_chart_to_tree=True`
|
|
812
|
+
(in which case the chart spec has already been pre-filtered upstream
|
|
813
|
+
and this set is expected to be empty by the time we get here).
|
|
718
814
|
- tree tips not in chart → fatal unless `prune_tree_to_chart=True`.
|
|
719
815
|
- (duplicate tree_strain_field values across tips → handled by the
|
|
720
816
|
separate `_check_no_duplicate_tip_strains`.)
|
|
@@ -728,7 +824,9 @@ def _reconcile_tips_and_strains(
|
|
|
728
824
|
chart_minus_tree = chart_set - tree_set
|
|
729
825
|
tree_minus_chart = tree_set - chart_set
|
|
730
826
|
|
|
731
|
-
|
|
827
|
+
chart_ok = not chart_minus_tree
|
|
828
|
+
tree_ok = not tree_minus_chart or prune_tree_to_chart
|
|
829
|
+
if chart_ok and tree_ok:
|
|
732
830
|
return
|
|
733
831
|
|
|
734
832
|
hints = _candidate_field_hints(
|
|
@@ -748,6 +846,7 @@ def _reconcile_tips_and_strains(
|
|
|
748
846
|
chart_minus_tree=chart_minus_tree,
|
|
749
847
|
tree_minus_chart=tree_minus_chart,
|
|
750
848
|
prune_tree_to_chart=prune_tree_to_chart,
|
|
849
|
+
prune_chart_to_tree=prune_chart_to_tree,
|
|
751
850
|
hints=hints,
|
|
752
851
|
)
|
|
753
852
|
)
|
|
@@ -762,14 +861,16 @@ def _format_strain_mismatch(
|
|
|
762
861
|
chart_minus_tree: set[str],
|
|
763
862
|
tree_minus_chart: set[str],
|
|
764
863
|
prune_tree_to_chart: bool,
|
|
864
|
+
prune_chart_to_tree: bool,
|
|
765
865
|
hints: list[str],
|
|
766
866
|
) -> str:
|
|
767
867
|
parts: list[str] = []
|
|
768
|
-
if chart_minus_tree:
|
|
868
|
+
if chart_minus_tree and not prune_chart_to_tree:
|
|
769
869
|
parts.append(
|
|
770
870
|
f"{len(chart_minus_tree)} chart strain(s) are not present in the "
|
|
771
|
-
"tree
|
|
772
|
-
"
|
|
871
|
+
"tree. Pass `prune_chart_to_tree=True` to drop the offending "
|
|
872
|
+
"chart rows automatically (use with care — this discards plot "
|
|
873
|
+
"data)."
|
|
773
874
|
)
|
|
774
875
|
if tree_minus_chart and not prune_tree_to_chart:
|
|
775
876
|
parts.append(
|
|
@@ -783,7 +884,7 @@ def _format_strain_mismatch(
|
|
|
783
884
|
)
|
|
784
885
|
parts.append("Sample chart_strain_field values: " f"{sorted(chart_strains)[:5]}")
|
|
785
886
|
parts.append("Sample tree_strain_field values: " f"{sorted(tree_strains)[:5]}")
|
|
786
|
-
if chart_minus_tree:
|
|
887
|
+
if chart_minus_tree and not prune_chart_to_tree:
|
|
787
888
|
parts.append(f"Sample chart-only values: {sorted(chart_minus_tree)[:5]}")
|
|
788
889
|
if tree_minus_chart and not prune_tree_to_chart:
|
|
789
890
|
parts.append(f"Sample tree-only values: {sorted(tree_minus_chart)[:5]}")
|
|
@@ -1380,6 +1481,11 @@ def _build_scale_bar_layer(
|
|
|
1380
1481
|
|
|
1381
1482
|
_LABEL_PAD_PX_MIN = 4
|
|
1382
1483
|
_LABEL_PAD_RATIO = 0.4 # `LABEL_PAD_PX = max(MIN, font_size * RATIO)`
|
|
1484
|
+
# Gap between the labels and the chart, kept separate from the tree-facing pad
|
|
1485
|
+
# above because it clears the chart's frame rather than the deepest tip, and so
|
|
1486
|
+
# wants a small fixed clearance rather than the pad's larger, faster-growing one.
|
|
1487
|
+
_LABEL_CHART_GAP_PX_MIN = 3
|
|
1488
|
+
_LABEL_CHART_GAP_RATIO = 0.2 # `LABEL_CHART_GAP_PX = max(MIN, font_size * RATIO)`
|
|
1383
1489
|
_LABEL_CHAR_PX_RATIO = 0.6 # rough proportional sans-serif glyph-width estimate
|
|
1384
1490
|
_LABEL_HALO_RATIO = 0.6 # white-halo strokeWidth as a fraction of font_size
|
|
1385
1491
|
|
|
@@ -1519,9 +1625,15 @@ def _build_tree_chart(
|
|
|
1519
1625
|
max_name_len = max((len(n) for n in names), default=0)
|
|
1520
1626
|
char_px = strain_label_font_size * _LABEL_CHAR_PX_RATIO
|
|
1521
1627
|
label_pad_px = max(_LABEL_PAD_PX_MIN, strain_label_font_size * _LABEL_PAD_RATIO)
|
|
1628
|
+
chart_gap_px = max(
|
|
1629
|
+
_LABEL_CHART_GAP_PX_MIN, strain_label_font_size * _LABEL_CHART_GAP_RATIO
|
|
1630
|
+
)
|
|
1522
1631
|
# Strip needs to fit the longest label plus `halo_px / 2` of halo
|
|
1523
|
-
# extension on the leader-facing side,
|
|
1524
|
-
|
|
1632
|
+
# extension on the leader-facing side, a pad between the tree and the
|
|
1633
|
+
# text, and a smaller gap between the text and the chart.
|
|
1634
|
+
label_pixel_width = (
|
|
1635
|
+
label_pad_px + chart_gap_px + max_name_len * char_px + halo_px / 2
|
|
1636
|
+
)
|
|
1525
1637
|
strip_pixel_width = label_pixel_width - shift_tree_loc
|
|
1526
1638
|
if strip_pixel_width <= 0:
|
|
1527
1639
|
raise ValueError(
|
|
@@ -1534,9 +1646,13 @@ def _build_tree_chart(
|
|
|
1534
1646
|
per_pixel = branch_span / tree_size if tree_size else 0.0
|
|
1535
1647
|
extra_branch_units = strip_pixel_width * per_pixel
|
|
1536
1648
|
chart_edge_branch = branch_max + extra_branch_units
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1649
|
+
# Anchor the labels `chart_gap_px` *inside* the chart-facing edge rather
|
|
1650
|
+
# than on it, so the text does not run up against the chart's frame. The
|
|
1651
|
+
# leaders stop at the same place, where the text begins.
|
|
1652
|
+
label_anchor_branch = chart_edge_branch - chart_gap_px * per_pixel
|
|
1653
|
+
tips_df = tips_df.assign(x_label=label_anchor_branch)
|
|
1654
|
+
leader_df = tips_df[tips_df["x"] < label_anchor_branch].assign(
|
|
1655
|
+
x2=label_anchor_branch
|
|
1540
1656
|
)
|
|
1541
1657
|
extended_tree_size = tree_size + strip_pixel_width
|
|
1542
1658
|
else:
|
|
@@ -1585,9 +1701,9 @@ def _build_tree_chart(
|
|
|
1585
1701
|
# When connect_leader_to_label is on, the branch domain is extended
|
|
1586
1702
|
# past `branch_max` to `chart_edge_branch` so the label strip has
|
|
1587
1703
|
# data-units to occupy; tips at `branch_max` still sit at pixel
|
|
1588
|
-
# `tree_size` on the panel. Each label's chart-facing edge
|
|
1589
|
-
#
|
|
1590
|
-
# tree on the left, left for tree on the right).
|
|
1704
|
+
# `tree_size` on the panel. Each label's chart-facing edge sits
|
|
1705
|
+
# `chart_gap_px` inside `chart_edge_branch`, aligned outward (right
|
|
1706
|
+
# for tree on the left, left for tree on the right).
|
|
1591
1707
|
if tree_location == "left":
|
|
1592
1708
|
branch_domain = [branch_min, chart_edge_branch]
|
|
1593
1709
|
text_align = "right"
|
|
@@ -1688,7 +1804,8 @@ def _build_tree_chart(
|
|
|
1688
1804
|
# strip at bottom, opposite the chart above).
|
|
1689
1805
|
# tree_location="bottom" → root at bottom → branch_max at top (label
|
|
1690
1806
|
# strip at top, opposite the chart below).
|
|
1691
|
-
# Each label's chart-facing edge
|
|
1807
|
+
# Each label's chart-facing edge sits `chart_gap_px` inside
|
|
1808
|
+
# `chart_edge_branch`.
|
|
1692
1809
|
# The text mark is rotated 270° (reads bottom-to-top), which maps
|
|
1693
1810
|
# pre-rotation `align="right"` to a top anchor (text extends down)
|
|
1694
1811
|
# and `align="left"` to a bottom anchor (text extends up). For tree
|
|
@@ -263,6 +263,7 @@ def _stack_config_options(command: Any) -> Any:
|
|
|
263
263
|
"Save the result as HTML / JSON / PNG / SVG / PDF "
|
|
264
264
|
"(format is dispatched on --output's extension)."
|
|
265
265
|
),
|
|
266
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
266
267
|
)
|
|
267
268
|
@click.option(
|
|
268
269
|
"--tree",
|
|
@@ -795,3 +795,30 @@ def test_cli_scale_bar_font_size(tmp_path):
|
|
|
795
795
|
collect_text_marks(tree_panel, text_marks)
|
|
796
796
|
sizes = [m.get("fontSize") for m in text_marks if "fontSize" in m]
|
|
797
797
|
assert 16.0 in sizes or 16 in sizes
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def test_cli_spacing(tmp_path):
|
|
801
|
+
"""`--spacing` reaches the saved spec's top-level concat gap."""
|
|
802
|
+
tree_path, chart_path, out_path = _cli_setup(
|
|
803
|
+
tmp_path, _attr_auspice(), _vertical_chart(["A", "B", "C", "D"])
|
|
804
|
+
)
|
|
805
|
+
_run_cli(
|
|
806
|
+
[
|
|
807
|
+
"--tree",
|
|
808
|
+
str(tree_path),
|
|
809
|
+
"--chart",
|
|
810
|
+
str(chart_path),
|
|
811
|
+
"--output",
|
|
812
|
+
str(out_path),
|
|
813
|
+
"--chart-strain-field",
|
|
814
|
+
"strain",
|
|
815
|
+
"--tree-strain-field",
|
|
816
|
+
"name",
|
|
817
|
+
"--branch-length",
|
|
818
|
+
"div",
|
|
819
|
+
"--spacing",
|
|
820
|
+
"9",
|
|
821
|
+
]
|
|
822
|
+
)
|
|
823
|
+
spec = json.loads(out_path.read_text())
|
|
824
|
+
assert spec["spacing"] == 9
|
|
@@ -27,6 +27,14 @@ def test_help_lists_data_options() -> None:
|
|
|
27
27
|
assert "--output" in result.output
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def test_short_help_flag_is_alias_for_long() -> None:
|
|
31
|
+
"""`-h` should produce identical output to `--help`."""
|
|
32
|
+
long_form = _runner().invoke(main, ["--help"])
|
|
33
|
+
short_form = _runner().invoke(main, ["-h"])
|
|
34
|
+
assert short_form.exit_code == 0
|
|
35
|
+
assert short_form.output == long_form.output
|
|
36
|
+
|
|
37
|
+
|
|
30
38
|
def test_help_lists_auto_generated_options() -> None:
|
|
31
39
|
"""A handful of PlotConfig-derived options should appear in --help."""
|
|
32
40
|
result = _runner().invoke(main, ["--help"])
|
|
@@ -37,6 +45,7 @@ def test_help_lists_auto_generated_options() -> None:
|
|
|
37
45
|
"--branch-length",
|
|
38
46
|
"--tree-size",
|
|
39
47
|
"--tree-location",
|
|
48
|
+
"--spacing",
|
|
40
49
|
"--tree-line-width",
|
|
41
50
|
"--scale-bar / --no-scale-bar",
|
|
42
51
|
"--strict-version / --no-strict-version",
|
|
@@ -285,9 +285,9 @@ def test_default_leader_endpoint_stops_at_branch_max() -> None:
|
|
|
285
285
|
|
|
286
286
|
def test_vertical_left_renders_text_with_right_align() -> None:
|
|
287
287
|
"""Vertical layout with `connect_leader_to_label=True` and default
|
|
288
|
-
`tree_location="left"`: tree on left, chart on right; labels
|
|
289
|
-
|
|
290
|
-
|
|
288
|
+
`tree_location="left"`: tree on left, chart on right; labels sit just
|
|
289
|
+
inside the panel's right (chart-facing) edge. Text mark uses
|
|
290
|
+
`align="right"` and `x_label > branch_max`."""
|
|
291
291
|
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
|
|
292
292
|
text = _text_layer(out)
|
|
293
293
|
assert text is not None
|
|
@@ -557,3 +557,48 @@ def test_default_off_keeps_user_axis_labels_intact() -> None:
|
|
|
557
557
|
assert axis.get("labelFontWeight") == "bold"
|
|
558
558
|
assert axis.get("labels") is not False
|
|
559
559
|
assert axis.get("ticks") is not False
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
# ---------- label inset from the chart-facing edge ----------
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_labels_sit_inside_the_chart_facing_edge() -> None:
|
|
566
|
+
"""Labels are anchored one pad *inside* `chart_edge_branch` rather than on
|
|
567
|
+
it, so the glyphs do not run up against the chart panel's frame. The panel
|
|
568
|
+
itself still extends to `chart_edge_branch`, which is the chart-facing end
|
|
569
|
+
of the branch scale's domain."""
|
|
570
|
+
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
|
|
571
|
+
text = _text_layer(out)
|
|
572
|
+
assert text is not None
|
|
573
|
+
# tree on the left → branch domain is [branch_min, chart_edge_branch]
|
|
574
|
+
chart_edge_branch = text["encoding"]["x"]["scale"]["domain"][1]
|
|
575
|
+
rows = _resolve_dataset(out.to_dict(), text)
|
|
576
|
+
assert rows
|
|
577
|
+
for row in rows:
|
|
578
|
+
assert row["x_label"] < chart_edge_branch
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_labels_sit_inside_the_chart_facing_edge_horizontal() -> None:
|
|
582
|
+
"""Same inset in horizontal layout, where the default tree_location is
|
|
583
|
+
"bottom" and the branch domain runs [branch_min, chart_edge_branch] on y."""
|
|
584
|
+
out = tree_annotated_plot.plot(_auspice(), _horizontal_chart(), **_on_kw())
|
|
585
|
+
text = _text_layer(out)
|
|
586
|
+
assert text is not None
|
|
587
|
+
chart_edge_branch = text["encoding"]["y"]["scale"]["domain"][1]
|
|
588
|
+
rows = _resolve_dataset(out.to_dict(), text)
|
|
589
|
+
assert rows
|
|
590
|
+
for row in rows:
|
|
591
|
+
assert row["x_label"] < chart_edge_branch
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def test_leaders_stop_where_the_labels_begin() -> None:
|
|
595
|
+
"""The leaders run to the label anchor rather than on to the panel edge, so
|
|
596
|
+
the dashed line meets the text instead of passing under it to the frame."""
|
|
597
|
+
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
|
|
598
|
+
text = _text_layer(out)
|
|
599
|
+
assert text is not None
|
|
600
|
+
anchors = {row["x_label"] for row in _resolve_dataset(out.to_dict(), text)}
|
|
601
|
+
assert len(anchors) == 1, f"expected one shared label anchor, got {anchors}"
|
|
602
|
+
(anchor,) = anchors
|
|
603
|
+
for row in _resolve_dataset(out.to_dict(), _leader_layer(out)):
|
|
604
|
+
assert row["x2"] == pytest.approx(anchor)
|
|
@@ -73,7 +73,7 @@ def _chart_for_strains(strains: list[str], *, height: int = 200) -> alt.Chart:
|
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
# ---------- chart-not-in-tree (
|
|
76
|
+
# ---------- chart-not-in-tree (fatal unless prune_chart_to_tree) ----------
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def test_chart_strain_not_in_tree_is_fatal_default() -> None:
|
|
@@ -90,9 +90,9 @@ def test_chart_strain_not_in_tree_is_fatal_default() -> None:
|
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def
|
|
93
|
+
def test_chart_strain_not_in_tree_is_fatal_with_only_tree_prune() -> None:
|
|
94
94
|
"""`prune_tree_to_chart=True` only drops *tree* tips. A chart strain
|
|
95
|
-
not in the tree still raises —
|
|
95
|
+
not in the tree still raises — the two flags are orthogonal."""
|
|
96
96
|
chart = _chart_for_strains(["A1", "A2", "X"])
|
|
97
97
|
with pytest.raises(ValueError, match="not present in the tree"):
|
|
98
98
|
tree_annotated_plot.plot(
|
|
@@ -105,6 +105,97 @@ def test_chart_strain_not_in_tree_is_fatal_even_with_prune() -> None:
|
|
|
105
105
|
)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def test_chart_strain_not_in_tree_succeeds_with_prune_chart_to_tree() -> None:
|
|
109
|
+
"""`prune_chart_to_tree=True` filters chart rows whose strain isn't a
|
|
110
|
+
tree tip. The resulting chart's strain-axis sort matches the kept tree
|
|
111
|
+
tip order, and the dropped strain is gone from the chart's data."""
|
|
112
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "B1", "B2", "X"])
|
|
113
|
+
out = tree_annotated_plot.plot(
|
|
114
|
+
_auspice_two_clades(),
|
|
115
|
+
chart,
|
|
116
|
+
chart_strain_field="strain",
|
|
117
|
+
tree_strain_field="name",
|
|
118
|
+
branch_length="div",
|
|
119
|
+
prune_chart_to_tree=True,
|
|
120
|
+
)
|
|
121
|
+
assert isinstance(out, alt.HConcatChart)
|
|
122
|
+
spec = out.to_dict()
|
|
123
|
+
# The strain-axis sort on the user-chart panel should be exactly the
|
|
124
|
+
# tree's tip order — no `X`.
|
|
125
|
+
sorts = []
|
|
126
|
+
for ch in out.hconcat:
|
|
127
|
+
ch_spec = ch.to_dict()
|
|
128
|
+
enc = ch_spec.get("encoding", {})
|
|
129
|
+
for channel in enc.values():
|
|
130
|
+
if isinstance(channel, dict) and channel.get("field") == "strain":
|
|
131
|
+
if isinstance(channel.get("sort"), list):
|
|
132
|
+
sorts.append(channel["sort"])
|
|
133
|
+
assert sorts, "expected at least one strain-axis encoding with a sort"
|
|
134
|
+
for s in sorts:
|
|
135
|
+
assert "X" not in s
|
|
136
|
+
assert set(s) <= {"A1", "A2", "A3", "B1", "B2"}
|
|
137
|
+
# And the user-chart data (now in `datasets`) should not contain X rows.
|
|
138
|
+
for rows in (spec.get("datasets") or {}).values():
|
|
139
|
+
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
|
|
140
|
+
if "strain" in rows[0]:
|
|
141
|
+
assert all(r["strain"] != "X" for r in rows)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_prune_chart_to_tree_zero_overlap_raises() -> None:
|
|
145
|
+
"""If pruning would drop every chart row (no overlap with tree tips),
|
|
146
|
+
raise a clear error rather than producing an empty plot."""
|
|
147
|
+
chart = _chart_for_strains(["X", "Y", "Z"])
|
|
148
|
+
with pytest.raises(ValueError, match="dropped every chart row"):
|
|
149
|
+
tree_annotated_plot.plot(
|
|
150
|
+
_auspice_two_clades(),
|
|
151
|
+
chart,
|
|
152
|
+
chart_strain_field="strain",
|
|
153
|
+
tree_strain_field="name",
|
|
154
|
+
branch_length="div",
|
|
155
|
+
prune_chart_to_tree=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_prune_chart_and_tree_combined() -> None:
|
|
160
|
+
"""Both flags may be on at once: the chart has extras (X) and the tree
|
|
161
|
+
has tips the chart lacks (B1, B2). Pruning is bidirectional."""
|
|
162
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "X"])
|
|
163
|
+
out = tree_annotated_plot.plot(
|
|
164
|
+
_auspice_two_clades(),
|
|
165
|
+
chart,
|
|
166
|
+
chart_strain_field="strain",
|
|
167
|
+
tree_strain_field="name",
|
|
168
|
+
branch_length="div",
|
|
169
|
+
prune_tree_to_chart=True,
|
|
170
|
+
prune_chart_to_tree=True,
|
|
171
|
+
)
|
|
172
|
+
assert isinstance(out, alt.HConcatChart)
|
|
173
|
+
# Final intersection is {A1, A2, A3}: chart loses X, tree loses B1/B2.
|
|
174
|
+
found_sort = None
|
|
175
|
+
for ch in out.hconcat:
|
|
176
|
+
enc = ch.to_dict().get("encoding", {})
|
|
177
|
+
for channel in enc.values():
|
|
178
|
+
if isinstance(channel, dict) and channel.get("field") == "strain":
|
|
179
|
+
if isinstance(channel.get("sort"), list):
|
|
180
|
+
found_sort = channel["sort"]
|
|
181
|
+
assert found_sort is not None
|
|
182
|
+
assert set(found_sort) == {"A1", "A2", "A3"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_prune_chart_to_tree_default_error_mentions_flag() -> None:
|
|
186
|
+
"""When chart has strains not in tree and the user hasn't opted in,
|
|
187
|
+
the error message should suggest `prune_chart_to_tree=True`."""
|
|
188
|
+
chart = _chart_for_strains(["A1", "A2", "A3", "B1", "B2", "X"])
|
|
189
|
+
with pytest.raises(ValueError, match="prune_chart_to_tree=True"):
|
|
190
|
+
tree_annotated_plot.plot(
|
|
191
|
+
_auspice_two_clades(),
|
|
192
|
+
chart,
|
|
193
|
+
chart_strain_field="strain",
|
|
194
|
+
tree_strain_field="name",
|
|
195
|
+
branch_length="div",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
108
199
|
# ---------- tree-not-in-chart (fatal unless prune) ----------
|
|
109
200
|
|
|
110
201
|
|
|
@@ -181,3 +181,55 @@ def test_right_with_x_encoded_strain_raises() -> None:
|
|
|
181
181
|
tree_annotated_plot.plot(
|
|
182
182
|
_auspice_4_tip(), _horizontal_chart(), tree_location="right", **_kw()
|
|
183
183
|
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------- spacing between the tree and chart panels ----------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize(
|
|
190
|
+
("chart_fn", "location"),
|
|
191
|
+
[
|
|
192
|
+
(_vertical_chart, "left"),
|
|
193
|
+
(_vertical_chart, "right"),
|
|
194
|
+
(_horizontal_chart, "top"),
|
|
195
|
+
(_horizontal_chart, "bottom"),
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
def test_spacing_defaults_to_zero(chart_fn, location) -> None:
|
|
199
|
+
"""The default butts the two panels together, as before `spacing` existed."""
|
|
200
|
+
out = tree_annotated_plot.plot(
|
|
201
|
+
_auspice_4_tip(), chart_fn(), tree_location=location, **_kw()
|
|
202
|
+
)
|
|
203
|
+
assert out.to_dict()["spacing"] == 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.parametrize(
|
|
207
|
+
("chart_fn", "location"),
|
|
208
|
+
[
|
|
209
|
+
(_vertical_chart, "left"),
|
|
210
|
+
(_vertical_chart, "right"),
|
|
211
|
+
(_horizontal_chart, "top"),
|
|
212
|
+
(_horizontal_chart, "bottom"),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
def test_spacing_propagates_to_the_concat(chart_fn, location) -> None:
|
|
216
|
+
"""An explicit `spacing` reaches the top-level concat spec, for every
|
|
217
|
+
`tree_location` — the gap is between the two panels, so it applies
|
|
218
|
+
uniformly to the hconcat and vconcat forms alike."""
|
|
219
|
+
out = tree_annotated_plot.plot(
|
|
220
|
+
_auspice_4_tip(), chart_fn(), tree_location=location, spacing=7, **_kw()
|
|
221
|
+
)
|
|
222
|
+
assert out.to_dict()["spacing"] == 7
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_spacing_does_not_disturb_panel_sizes() -> None:
|
|
226
|
+
"""Spacing is applied along the branch axis, between panels, so neither
|
|
227
|
+
panel's own dimensions change and strain-row alignment is untouched."""
|
|
228
|
+
out_zero = tree_annotated_plot.plot(_auspice_4_tip(), _vertical_chart(), **_kw())
|
|
229
|
+
out_gap = tree_annotated_plot.plot(
|
|
230
|
+
_auspice_4_tip(), _vertical_chart(), spacing=12, **_kw()
|
|
231
|
+
)
|
|
232
|
+
d_zero, d_gap = out_zero.to_dict(), out_gap.to_dict()
|
|
233
|
+
for i in (0, 1):
|
|
234
|
+
assert d_zero["hconcat"][i]["width"] == d_gap["hconcat"][i]["width"]
|
|
235
|
+
assert d_zero["hconcat"][i].get("height") == d_gap["hconcat"][i].get("height")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|