tree-annotated-plot 0.2.0__tar.gz → 0.2.2__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.0 → tree_annotated_plot-0.2.2}/CHANGELOG.md +32 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/PKG-INFO +1 -1
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/docs/examples.md +13 -4
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/pyproject.toml +1 -1
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/scripts/generate_docs_assets.py +6 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_plot.py +214 -71
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_color_tree.py +34 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_introspection.py +68 -1
- tree_annotated_plot-0.2.2/tests/test_line_order.py +258 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_plot.py +67 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/.github/workflows/docs.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/.github/workflows/release.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/.github/workflows/tests.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/.gitignore +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/CLAUDE.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/LICENSE +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/README.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/docs/cli.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/docs/index.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/docs/python-api.md +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/examples/fetch_auspice_data.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/examples/synthetic_example.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/mkdocs.yml +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/scripts/build_docs.sh +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/scripts/check.sh +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/__init__.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_color.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_config.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_tree.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/cli.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_appearance_tuning.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_branch_length.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_chart_loader.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_cli.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_examples_run.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_label_connection.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_plot_config.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_real_data.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_reconciliation.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_scale_bar.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_style.py +0 -0
- {tree_annotated_plot-0.2.0 → tree_annotated_plot-0.2.2}/tests/test_tree_location.py +0 -0
|
@@ -5,6 +5,38 @@ 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
|
+
## [0.2.2] - 2026-05-09
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `mark_line`, `mark_trail`, and `mark_area` now connect points in tree
|
|
13
|
+
tip order regardless of other encodings on the chart. Previously, a
|
|
14
|
+
user chart with an explicit categorical color-scale `domain` rendered
|
|
15
|
+
with crisscrossing line segments because Vega-Lite's default
|
|
16
|
+
connection-order heuristic ignored the strain-axis sort. The package
|
|
17
|
+
now attaches a `calculate` transform that derives a per-row tip rank
|
|
18
|
+
and points the `order` channel at that field on these marks. Any
|
|
19
|
+
user-supplied `order` is left in place.
|
|
20
|
+
- When `color_tree_by` is used together with a user chart that has its
|
|
21
|
+
own `color` encoding (e.g. a titer plot colored by `cell_line`), the
|
|
22
|
+
user chart's marks no longer disappear. The concat container now
|
|
23
|
+
resolves the `color` scale as `independent` so the tree's
|
|
24
|
+
`color_value:N` scale (with its tree-specific domain) is not merged
|
|
25
|
+
with the user chart's color scale.
|
|
26
|
+
|
|
27
|
+
## [0.2.1] - 2026-05-06
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Apply tree-tip ordering to chart axes that use untyped Altair
|
|
32
|
+
shorthand (`alt.Y("strain")` without a `:N` / `:O` type suffix).
|
|
33
|
+
Previously the sort override was silently skipped, so the chart
|
|
34
|
+
rendered in data order instead of tree order.
|
|
35
|
+
- An internal consistency check now raises if the spec-level walk
|
|
36
|
+
and the live-object walk over the user's chart ever disagree on
|
|
37
|
+
the number of strain-axis encodings, so silent skips like the
|
|
38
|
+
one above can't recur in another shape.
|
|
39
|
+
|
|
8
40
|
## [0.2.0] - 2026-05-06
|
|
9
41
|
|
|
10
42
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tree-annotated-plot
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -105,8 +105,12 @@ edge.
|
|
|
105
105
|
|
|
106
106
|
The H3N2 example above is rendered with `color_tree_by="subclade"`,
|
|
107
107
|
which colors the tree's branches and tip circles by the
|
|
108
|
-
`node_attrs.subclade` value at each node
|
|
109
|
-
|
|
108
|
+
`node_attrs.subclade` value at each node. The package's default places
|
|
109
|
+
the categorical legend at the bottom of the combined plot; here we pass
|
|
110
|
+
`tree_color_legend_format={"orient": "left"}` to push it to the left
|
|
111
|
+
side instead, so it doesn't compete with the cohort-selection legend
|
|
112
|
+
already sitting below the chart. See "Color the tree" below for the
|
|
113
|
+
full set of options.
|
|
110
114
|
|
|
111
115
|
### Optional: connect leaders all the way to the labels
|
|
112
116
|
|
|
@@ -156,8 +160,11 @@ Colors match what you'd see on the Nextstrain view of the same tree —
|
|
|
156
160
|
either from the JSON's palette information when the build provides it,
|
|
157
161
|
or from the same default palette Auspice uses when it doesn't.
|
|
158
162
|
Categories are ordered by descending frequency in both cases. Missing
|
|
159
|
-
values render in gray
|
|
160
|
-
combined plot
|
|
163
|
+
values render in gray. By default the legend is drawn at the bottom of
|
|
164
|
+
the combined plot; the H3N2 examples in this section pass
|
|
165
|
+
`tree_color_legend_format={"orient": "left"}` to move it to the left
|
|
166
|
+
instead, since the bottom edge already carries the cohort-selection
|
|
167
|
+
legend.
|
|
161
168
|
|
|
162
169
|
The example below colors the same H3N2 chart by genotype at HA1
|
|
163
170
|
site 158, which has two mutations in the tree (`N158K`, `N158D`) and
|
|
@@ -277,6 +284,7 @@ tree-annotated-plot \
|
|
|
277
284
|
--scale-bar \
|
|
278
285
|
--branch-length-units substitutions \
|
|
279
286
|
--color-tree-by subclade \
|
|
287
|
+
--tree-color-legend-format '{"orient":"left"}' \
|
|
280
288
|
--output examples/data/h3n2_combined.json
|
|
281
289
|
```
|
|
282
290
|
|
|
@@ -295,6 +303,7 @@ out = tree_annotated_plot.plot(
|
|
|
295
303
|
scale_bar=True,
|
|
296
304
|
branch_length_units="substitutions",
|
|
297
305
|
color_tree_by="subclade",
|
|
306
|
+
tree_color_legend_format={"orient": "left"},
|
|
298
307
|
)
|
|
299
308
|
```
|
|
300
309
|
|
|
@@ -122,7 +122,11 @@ def _render_kikawa() -> None:
|
|
|
122
122
|
# Color H3N2 by subclade so the docs SVG matches what users see
|
|
123
123
|
# on Nextstrain. The Auspice JSON's meta.colorings.subclade has
|
|
124
124
|
# no `scale` defined, so colors come from the default palette.
|
|
125
|
+
# Place the subclade legend on the left so it doesn't compete
|
|
126
|
+
# with the cohort-selection legend that already sits at the
|
|
127
|
+
# bottom of the chart.
|
|
125
128
|
plot_kwargs["color_tree_by"] = "subclade"
|
|
129
|
+
plot_kwargs["tree_color_legend_format"] = {"orient": "left"}
|
|
126
130
|
out = tree_annotated_plot.plot(
|
|
127
131
|
DATA_DIR / f"flu-seqneut-2025to2026_{subtype}.json",
|
|
128
132
|
chart,
|
|
@@ -155,6 +159,7 @@ def _render_kikawa() -> None:
|
|
|
155
159
|
strain_label_font_size=9,
|
|
156
160
|
shift_tree_loc=60,
|
|
157
161
|
color_tree_by="subclade",
|
|
162
|
+
tree_color_legend_format={"orient": "left"},
|
|
158
163
|
)
|
|
159
164
|
_save_pair(out, "h3n2_combined_label_connect")
|
|
160
165
|
|
|
@@ -180,6 +185,7 @@ def _render_kikawa() -> None:
|
|
|
180
185
|
scale_bar=True,
|
|
181
186
|
branch_length_units="substitutions",
|
|
182
187
|
color_tree_by="genotype:HA1:158",
|
|
188
|
+
tree_color_legend_format={"orient": "left"},
|
|
183
189
|
)
|
|
184
190
|
_save_pair(out, "h3n2_combined_genotype_158")
|
|
185
191
|
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import math
|
|
8
8
|
import re
|
|
9
9
|
import warnings
|
|
10
|
+
from collections.abc import Iterator
|
|
10
11
|
from html.parser import HTMLParser
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Literal
|
|
@@ -223,11 +224,37 @@ def _build(
|
|
|
223
224
|
legend_show=config.tree_color_legend_show,
|
|
224
225
|
)
|
|
225
226
|
|
|
226
|
-
new_chart =
|
|
227
|
-
|
|
227
|
+
new_chart = copy.deepcopy(chart)
|
|
228
|
+
suppress_axis_chrome = config.connect_leader_to_label
|
|
229
|
+
n_hits = 0
|
|
230
|
+
for ch in _iter_strain_axis_channels(new_chart, config.chart_strain_field):
|
|
231
|
+
ch.sort = list(tip_names)
|
|
232
|
+
if suppress_axis_chrome:
|
|
233
|
+
ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
|
|
234
|
+
n_hits += 1
|
|
235
|
+
_check_walker_hits("strain-axis update", n_hits, len(axis_hits), axis)
|
|
236
|
+
|
|
237
|
+
# Pin line/trail/area connection order to tip order via Vega-Lite's
|
|
238
|
+
# `order` channel. Without this, an explicit categorical `color` scale
|
|
239
|
+
# `domain` (or other encoding choices) can shift Vega-Lite's
|
|
240
|
+
# connection-order heuristic away from the strain-axis sort, causing
|
|
241
|
+
# lines to crisscross. The order channel's `sort` only accepts
|
|
242
|
+
# ascending/descending, so we attach a `calculate` transform that
|
|
243
|
+
# computes a per-row tip rank and point `order` at that derived
|
|
244
|
+
# quantitative field. User-supplied `order` always wins.
|
|
245
|
+
rank_expr = (
|
|
246
|
+
f"indexof({json.dumps(list(tip_names))}, "
|
|
247
|
+
f"datum[{json.dumps(config.chart_strain_field)}])"
|
|
228
248
|
)
|
|
229
|
-
|
|
230
|
-
|
|
249
|
+
for node in _iter_connection_order_nodes(new_chart, config.chart_strain_field):
|
|
250
|
+
enc = _live_attr(node, "encoding")
|
|
251
|
+
if _live_attr(enc, "order") is not None:
|
|
252
|
+
continue
|
|
253
|
+
existing = _live_attr(node, "transform") or []
|
|
254
|
+
node.transform = list(existing) + [
|
|
255
|
+
{"calculate": rank_expr, "as": _TIP_ORDER_RANK_FIELD}
|
|
256
|
+
]
|
|
257
|
+
enc.order = alt.Order(f"{_TIP_ORDER_RANK_FIELD}:Q")
|
|
231
258
|
hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
|
|
232
259
|
|
|
233
260
|
combined = _concat_for_location(
|
|
@@ -271,22 +298,32 @@ def _concat_for_location(
|
|
|
271
298
|
user_chart: alt.TopLevelMixin,
|
|
272
299
|
location: TreeLocation,
|
|
273
300
|
) -> alt.HConcatChart | alt.VConcatChart:
|
|
274
|
-
"""Concat tree and chart in the order implied by the tree's location.
|
|
301
|
+
"""Concat tree and chart in the order implied by the tree's location.
|
|
302
|
+
|
|
303
|
+
The strain axis is resolved independent so the tree and chart can use
|
|
304
|
+
different scales on that axis (the tree's branch length vs. the chart's
|
|
305
|
+
measurement value), while still sharing the orthogonal strain axis. The
|
|
306
|
+
`color` scale is also resolved independent: when ``color_tree_by`` is set
|
|
307
|
+
the tree panel emits a `color_value:N` color scale with a tree-specific
|
|
308
|
+
domain, and Vega-Lite's default of sharing color across concat views
|
|
309
|
+
would merge it with any color encoding on the user's chart, hiding
|
|
310
|
+
user-chart marks whose color values aren't in the tree's domain.
|
|
311
|
+
"""
|
|
275
312
|
if location == "left":
|
|
276
313
|
return alt.hconcat(tree_chart, user_chart, spacing=0).resolve_scale(
|
|
277
|
-
y="independent"
|
|
314
|
+
y="independent", color="independent"
|
|
278
315
|
)
|
|
279
316
|
if location == "right":
|
|
280
317
|
return alt.hconcat(user_chart, tree_chart, spacing=0).resolve_scale(
|
|
281
|
-
y="independent"
|
|
318
|
+
y="independent", color="independent"
|
|
282
319
|
)
|
|
283
320
|
if location == "top":
|
|
284
321
|
return alt.vconcat(tree_chart, user_chart, spacing=0).resolve_scale(
|
|
285
|
-
x="independent"
|
|
322
|
+
x="independent", color="independent"
|
|
286
323
|
)
|
|
287
324
|
if location == "bottom":
|
|
288
325
|
return alt.vconcat(user_chart, tree_chart, spacing=0).resolve_scale(
|
|
289
|
-
x="independent"
|
|
326
|
+
x="independent", color="independent"
|
|
290
327
|
)
|
|
291
328
|
raise ValueError(f"unreachable: tree_location={location!r}")
|
|
292
329
|
|
|
@@ -916,11 +953,45 @@ def _enumerate_tree_tip_attrs(
|
|
|
916
953
|
return out
|
|
917
954
|
|
|
918
955
|
|
|
919
|
-
def
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
956
|
+
def _check_walker_hits(operation: str, actual: int, expected: int, axis: str) -> None:
|
|
957
|
+
"""Cross-check the live-object walk's hit count against the spec walk's.
|
|
958
|
+
|
|
959
|
+
`_find_strain_encoding` walks the chart's serialized dict form to count
|
|
960
|
+
how many `x`/`y` strain encodings the chart has and to validate
|
|
961
|
+
consistency (axis agreement, type=nominal/ordinal, etc.). The live
|
|
962
|
+
iteration in `_build` (driven by `_iter_strain_axis_channels`) must
|
|
963
|
+
visit exactly the same number of encodings — fewer would silently skip
|
|
964
|
+
applying the tree's tip order (or the axis suppression) to part of the
|
|
965
|
+
chart, more would mean we mutated structures the dict walker doesn't
|
|
966
|
+
know about.
|
|
967
|
+
|
|
968
|
+
The check is symmetric (`!=`) rather than one-sided (`<`) because
|
|
969
|
+
either direction signals that spec-level introspection and
|
|
970
|
+
live-object traversal have diverged, and continuing would render an
|
|
971
|
+
unverified chart.
|
|
972
|
+
"""
|
|
973
|
+
if actual != expected:
|
|
974
|
+
raise RuntimeError(
|
|
975
|
+
f"internal consistency check failed for {operation!r}: "
|
|
976
|
+
f"_find_strain_encoding located {expected} strain {axis!r}-axis "
|
|
977
|
+
f"encoding(s) in the chart spec, but the live-object walk "
|
|
978
|
+
f"updated {actual}. Spec-level and live-object traversal have "
|
|
979
|
+
"diverged, which would render the chart with a wrong tip order "
|
|
980
|
+
"or leave axis chrome behind. Please file a bug at "
|
|
981
|
+
"https://github.com/jbloomlab/tree-annotated-plot/issues with a "
|
|
982
|
+
"minimal reproducer."
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _iter_strain_axis_channels(node: Any, chart_strain_field: str) -> Iterator[Any]:
|
|
987
|
+
"""Yield every live x/y channel object whose field matches
|
|
988
|
+
`chart_strain_field`.
|
|
989
|
+
|
|
990
|
+
Pure read — no mutation, no count. The caller iterates and applies
|
|
991
|
+
whatever mutation it needs (currently `sort` and, when
|
|
992
|
+
`connect_leader_to_label=True`, an axis-suppression `alt.Axis(...)`),
|
|
993
|
+
counting hits as it goes so the cross-check in `_check_walker_hits`
|
|
994
|
+
can compare against the spec walker.
|
|
924
995
|
|
|
925
996
|
Walks the live altair object tree (Chart / LayerChart / FacetChart /
|
|
926
997
|
HConcatChart / VConcatChart / ConcatChart) rather than its dict form,
|
|
@@ -929,62 +1000,25 @@ def _apply_tree_order_to_chart_object(
|
|
|
929
1000
|
dict approach has to fight. Modifying the object in place is robust as
|
|
930
1001
|
long as altair's container attribute names hold (.hconcat / .vconcat /
|
|
931
1002
|
.concat / .layer / .spec / .encoding), which is stable in altair 5+.
|
|
1003
|
+
`FacetChart.spec` is recursed into unconditionally so we descend to the
|
|
1004
|
+
inner LayerChart / Chart that actually carries the encoding (gating on
|
|
1005
|
+
`spec.encoding is not None` would skip LayerCharts, whose encodings
|
|
1006
|
+
live on their layers rather than at the top level).
|
|
932
1007
|
"""
|
|
933
|
-
new_chart = copy.deepcopy(chart)
|
|
934
|
-
_walk_and_apply_sort(new_chart, chart_strain_field, sort_order)
|
|
935
|
-
return new_chart
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def _walk_and_apply_sort(
|
|
939
|
-
node: Any, chart_strain_field: str, sort_order: list[str]
|
|
940
|
-
) -> None:
|
|
941
|
-
"""Recursively set sort on every encoding whose field == chart_strain_field
|
|
942
|
-
on a live altair chart object."""
|
|
943
1008
|
enc = _live_attr(node, "encoding")
|
|
944
1009
|
if enc is not None:
|
|
945
1010
|
for channel in ("x", "y"):
|
|
946
1011
|
ch = _live_attr(enc, channel)
|
|
947
1012
|
if ch is not None and _channel_field(ch) == chart_strain_field:
|
|
948
|
-
ch
|
|
1013
|
+
yield ch
|
|
949
1014
|
for attr in ("hconcat", "vconcat", "concat", "layer"):
|
|
950
1015
|
sub = _live_attr(node, attr)
|
|
951
1016
|
if isinstance(sub, list):
|
|
952
1017
|
for s in sub:
|
|
953
|
-
|
|
954
|
-
# FacetChart.spec is the chart being faceted; recurse unconditionally so
|
|
955
|
-
# we descend into the inner LayerChart / Chart that actually carries the
|
|
956
|
-
# encoding. Gating on `spec.encoding is not None` was wrong because a
|
|
957
|
-
# LayerChart has no top-level encoding — its encodings live on its layers.
|
|
1018
|
+
yield from _iter_strain_axis_channels(s, chart_strain_field)
|
|
958
1019
|
spec = _live_attr(node, "spec")
|
|
959
1020
|
if spec is not None:
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
def _suppress_chart_strain_axis(
|
|
964
|
-
chart: alt.TopLevelMixin, chart_strain_field: str
|
|
965
|
-
) -> None:
|
|
966
|
-
"""Hide labels, ticks, axis line, and title on every chart strain-axis encoding.
|
|
967
|
-
|
|
968
|
-
Walks the live altair object the same way `_walk_and_apply_sort` does
|
|
969
|
-
and replaces the matching encoding's ``axis`` with one that suppresses
|
|
970
|
-
every visible bit of axis chrome. This **overrides** any user-supplied
|
|
971
|
-
``axis=alt.Axis(...)`` on those encodings; that's documented on the
|
|
972
|
-
``connect_leader_to_label`` description in `_config.py`.
|
|
973
|
-
"""
|
|
974
|
-
enc = _live_attr(chart, "encoding")
|
|
975
|
-
if enc is not None:
|
|
976
|
-
for channel in ("x", "y"):
|
|
977
|
-
ch = _live_attr(enc, channel)
|
|
978
|
-
if ch is not None and _channel_field(ch) == chart_strain_field:
|
|
979
|
-
ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
|
|
980
|
-
for attr in ("hconcat", "vconcat", "concat", "layer"):
|
|
981
|
-
sub = _live_attr(chart, attr)
|
|
982
|
-
if isinstance(sub, list):
|
|
983
|
-
for s in sub:
|
|
984
|
-
_suppress_chart_strain_axis(s, chart_strain_field)
|
|
985
|
-
spec = _live_attr(chart, "spec")
|
|
986
|
-
if spec is not None:
|
|
987
|
-
_suppress_chart_strain_axis(spec, chart_strain_field)
|
|
1021
|
+
yield from _iter_strain_axis_channels(spec, chart_strain_field)
|
|
988
1022
|
|
|
989
1023
|
|
|
990
1024
|
def _live_attr(obj: Any, name: str) -> Any:
|
|
@@ -997,6 +1031,65 @@ def _live_attr(obj: Any, name: str) -> Any:
|
|
|
997
1031
|
return v
|
|
998
1032
|
|
|
999
1033
|
|
|
1034
|
+
# Vega-Lite marks whose drawing order along the discrete axis is determined
|
|
1035
|
+
# by the order channel (rule 1 of the connection-order fallback chain). For
|
|
1036
|
+
# these marks pinning the order channel to a per-row tip-order rank ensures
|
|
1037
|
+
# the line / trail / area connects strains in tip order regardless of how
|
|
1038
|
+
# Vega-Lite would otherwise resolve the fallback (e.g. when an explicit
|
|
1039
|
+
# color-scale `domain` shifts it away from the y-axis sort).
|
|
1040
|
+
_CONNECTION_ORDER_MARKS = frozenset({"line", "trail", "area"})
|
|
1041
|
+
|
|
1042
|
+
# Derived field name appended to each connection-order mark's transform
|
|
1043
|
+
# pipeline. Chosen to be unlikely to collide with user data; private prefix
|
|
1044
|
+
# makes the intent clear if anyone inspects the rendered spec.
|
|
1045
|
+
_TIP_ORDER_RANK_FIELD = "_tap_strain_order_idx"
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _mark_type(node: Any) -> str | None:
|
|
1049
|
+
"""Return the mark type string for a chart node, or None.
|
|
1050
|
+
|
|
1051
|
+
Handles altair's two mark forms: the plain string (e.g. ``"line"`` from
|
|
1052
|
+
``chart.mark_line()``) and the ``MarkDef`` object (e.g. from
|
|
1053
|
+
``chart.mark_line(point=True)``). Container nodes (LayerChart,
|
|
1054
|
+
HConcatChart, etc.) have no mark and return None.
|
|
1055
|
+
"""
|
|
1056
|
+
mark = _live_attr(node, "mark")
|
|
1057
|
+
if mark is None:
|
|
1058
|
+
return None
|
|
1059
|
+
if isinstance(mark, str):
|
|
1060
|
+
return mark
|
|
1061
|
+
type_ = _live_attr(mark, "type")
|
|
1062
|
+
return type_ if isinstance(type_, str) else None
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _iter_connection_order_nodes(node: Any, chart_strain_field: str) -> Iterator[Any]:
|
|
1066
|
+
"""Yield the live chart node for every node whose mark is in
|
|
1067
|
+
``_CONNECTION_ORDER_MARKS`` and whose ``x``/``y`` strain encoding
|
|
1068
|
+
matches ``chart_strain_field``.
|
|
1069
|
+
|
|
1070
|
+
Mirrors `_iter_strain_axis_channels`'s traversal (hconcat / vconcat /
|
|
1071
|
+
concat / layer / spec descent). Yields the node itself (not just the
|
|
1072
|
+
encoding) so the caller can attach both a ``calculate`` transform and
|
|
1073
|
+
the ``order`` channel.
|
|
1074
|
+
"""
|
|
1075
|
+
if _mark_type(node) in _CONNECTION_ORDER_MARKS:
|
|
1076
|
+
enc = _live_attr(node, "encoding")
|
|
1077
|
+
if enc is not None:
|
|
1078
|
+
for channel in ("x", "y"):
|
|
1079
|
+
ch = _live_attr(enc, channel)
|
|
1080
|
+
if ch is not None and _channel_field(ch) == chart_strain_field:
|
|
1081
|
+
yield node
|
|
1082
|
+
break
|
|
1083
|
+
for attr in ("hconcat", "vconcat", "concat", "layer"):
|
|
1084
|
+
sub = _live_attr(node, attr)
|
|
1085
|
+
if isinstance(sub, list):
|
|
1086
|
+
for s in sub:
|
|
1087
|
+
yield from _iter_connection_order_nodes(s, chart_strain_field)
|
|
1088
|
+
spec = _live_attr(node, "spec")
|
|
1089
|
+
if spec is not None:
|
|
1090
|
+
yield from _iter_connection_order_nodes(spec, chart_strain_field)
|
|
1091
|
+
|
|
1092
|
+
|
|
1000
1093
|
def _pop_toplevel_only_attrs(
|
|
1001
1094
|
chart: alt.TopLevelMixin,
|
|
1002
1095
|
) -> tuple[Any, dict]:
|
|
@@ -1044,21 +1137,71 @@ def _apply_combined_config(combined: alt.HConcatChart, hoisted_config: Any) -> N
|
|
|
1044
1137
|
def _channel_field(ch: Any) -> str | None:
|
|
1045
1138
|
"""Read the underlying `field` string from an altair channel object.
|
|
1046
1139
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1140
|
+
Returns the field name when the channel references a data field (either
|
|
1141
|
+
via the `field=` keyword or via positional shorthand like
|
|
1142
|
+
`alt.Y("strain")` / `alt.Y("strain:N")` / `alt.Y("mean(titer):Q")`).
|
|
1143
|
+
Returns `None` when the channel has no field at all (a `value=` /
|
|
1144
|
+
`datum=` constant encoding). Raises `ValueError` when the channel's
|
|
1145
|
+
`_kwds` shape is unrecognized — silent fallthrough hid a real bug
|
|
1146
|
+
where untyped shorthand axes were never reordered to match the tree.
|
|
1147
|
+
|
|
1148
|
+
Reads `ch._kwds` directly rather than going through `ch.to_dict()`:
|
|
1149
|
+
altair's `to_dict()` on a bare channel raises when the shorthand has
|
|
1150
|
+
no explicit type (e.g. `alt.Y("strain")`), because the `nominal` /
|
|
1151
|
+
`quantitative` inference needs the chart's data context. That
|
|
1152
|
+
exception is what the previous catch-all hid.
|
|
1049
1153
|
"""
|
|
1050
|
-
|
|
1051
|
-
if not
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1154
|
+
kwds = getattr(ch, "_kwds", None)
|
|
1155
|
+
if not isinstance(kwds, dict):
|
|
1156
|
+
raise ValueError(
|
|
1157
|
+
f"channel object {type(ch).__name__} has no `_kwds` mapping; "
|
|
1158
|
+
"this isn't a recognized altair channel encoding."
|
|
1159
|
+
)
|
|
1160
|
+
field = kwds.get("field")
|
|
1161
|
+
if field is not None and field is not alt.Undefined:
|
|
1162
|
+
# `from_dict`-roundtripped channels store the field as a
|
|
1163
|
+
# `FieldName(SchemaBase)` wrapper rather than a plain str; its
|
|
1164
|
+
# `to_dict()` returns the raw string, while `str(...)` returns the
|
|
1165
|
+
# repr `FieldName('x')`. Cover both.
|
|
1166
|
+
if isinstance(field, str):
|
|
1167
|
+
if field:
|
|
1168
|
+
return field
|
|
1169
|
+
elif hasattr(field, "to_dict"):
|
|
1170
|
+
unwrapped = field.to_dict()
|
|
1171
|
+
if isinstance(unwrapped, str) and unwrapped:
|
|
1172
|
+
return unwrapped
|
|
1173
|
+
raise ValueError(
|
|
1174
|
+
f"channel field wrapper {type(field).__name__} unwrapped "
|
|
1175
|
+
f"to {unwrapped!r}; expected a non-empty string."
|
|
1176
|
+
)
|
|
1177
|
+
else:
|
|
1178
|
+
raise ValueError(
|
|
1179
|
+
f"channel field has unexpected value {field!r} "
|
|
1180
|
+
f"(type {type(field).__name__}); expected a string."
|
|
1181
|
+
)
|
|
1182
|
+
shorthand = kwds.get("shorthand")
|
|
1183
|
+
if shorthand is None or shorthand is alt.Undefined:
|
|
1056
1184
|
return None
|
|
1057
|
-
if isinstance(
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1185
|
+
if not isinstance(shorthand, str) or not shorthand:
|
|
1186
|
+
raise ValueError(
|
|
1187
|
+
f"channel shorthand has unexpected value {shorthand!r}; "
|
|
1188
|
+
"expected a string like 'strain', 'strain:N', or "
|
|
1189
|
+
"'mean(strain):Q'."
|
|
1190
|
+
)
|
|
1191
|
+
# Shorthand grammar: '[<aggregate>(]<field>[)][:<type>]'.
|
|
1192
|
+
bare = shorthand.split(":", 1)[0]
|
|
1193
|
+
if "(" in bare:
|
|
1194
|
+
if not bare.endswith(")"):
|
|
1195
|
+
raise ValueError(
|
|
1196
|
+
f"channel shorthand {shorthand!r} has an unbalanced "
|
|
1197
|
+
"aggregate wrapper; expected 'aggregate(field)[:type]'."
|
|
1198
|
+
)
|
|
1199
|
+
bare = bare[bare.index("(") + 1 : -1]
|
|
1200
|
+
if not bare:
|
|
1201
|
+
raise ValueError(
|
|
1202
|
+
f"channel shorthand {shorthand!r} parsed to an empty field name."
|
|
1203
|
+
)
|
|
1204
|
+
return bare
|
|
1062
1205
|
|
|
1063
1206
|
|
|
1064
1207
|
def _chart_strain_dim(
|
|
@@ -663,6 +663,40 @@ def test_color_tree_by_legend_hides_internal_only_unknown():
|
|
|
663
663
|
assert "unknown" in enc["scale"]["domain"]
|
|
664
664
|
|
|
665
665
|
|
|
666
|
+
def test_color_tree_by_color_scale_independent_from_user_chart():
|
|
667
|
+
"""When the tree is colored, the concat container must resolve the color
|
|
668
|
+
scale as `independent` so the tree's `color_value:N` scale doesn't merge
|
|
669
|
+
with a user-chart color encoding (which would hide user marks whose
|
|
670
|
+
color values aren't in the tree's domain)."""
|
|
671
|
+
df = pd.DataFrame(
|
|
672
|
+
{
|
|
673
|
+
"strain": ["A", "B", "C", "D"] * 2,
|
|
674
|
+
"titer": [1.0, 2.0, 4.0, 8.0, 1.5, 2.5, 4.5, 8.5],
|
|
675
|
+
"cell_line": ["X", "X", "X", "X", "Y", "Y", "Y", "Y"],
|
|
676
|
+
}
|
|
677
|
+
)
|
|
678
|
+
user_chart = (
|
|
679
|
+
alt.Chart(df)
|
|
680
|
+
.mark_line(point=True)
|
|
681
|
+
.encode(
|
|
682
|
+
x="titer:Q",
|
|
683
|
+
y=alt.Y("strain:N"),
|
|
684
|
+
color=alt.Color("cell_line:N"),
|
|
685
|
+
)
|
|
686
|
+
.properties(width=200, height=200)
|
|
687
|
+
)
|
|
688
|
+
out = tree_annotated_plot.plot(
|
|
689
|
+
_attr_auspice(),
|
|
690
|
+
user_chart,
|
|
691
|
+
**_kw(),
|
|
692
|
+
color_tree_by="subclade",
|
|
693
|
+
)
|
|
694
|
+
spec = out.to_dict()
|
|
695
|
+
resolve = spec.get("resolve") or {}
|
|
696
|
+
scale = resolve.get("scale") or {}
|
|
697
|
+
assert scale.get("color") == "independent"
|
|
698
|
+
|
|
699
|
+
|
|
666
700
|
# -----------------------------------------------------------------------------
|
|
667
701
|
# CLI
|
|
668
702
|
# -----------------------------------------------------------------------------
|
|
@@ -15,7 +15,7 @@ import altair as alt
|
|
|
15
15
|
import pandas as pd
|
|
16
16
|
import pytest
|
|
17
17
|
|
|
18
|
-
from tree_annotated_plot._plot import _find_strain_encoding
|
|
18
|
+
from tree_annotated_plot._plot import _channel_field, _find_strain_encoding
|
|
19
19
|
|
|
20
20
|
DATA_DIR = Path(__file__).resolve().parent.parent / "examples" / "data"
|
|
21
21
|
|
|
@@ -139,3 +139,70 @@ def test_find_strain_encoding_quantitative_type_raises() -> None:
|
|
|
139
139
|
spec = _flat_chart_spec(typ="quantitative")
|
|
140
140
|
with pytest.raises(ValueError, match="type='quantitative'"):
|
|
141
141
|
_find_strain_encoding(spec, "strain")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------- _channel_field: covers the "untyped shorthand" silent-skip bug.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_channel_field_typed_shorthand() -> None:
|
|
148
|
+
"""`alt.Y('strain:N')` — the form every existing test uses."""
|
|
149
|
+
ch = alt.Y("strain:N")
|
|
150
|
+
assert _channel_field(ch) == "strain"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_channel_field_untyped_shorthand() -> None:
|
|
154
|
+
"""`alt.Y('strain')` without a type — the form that triggered the
|
|
155
|
+
original silent-skip bug. `to_dict()` on this channel raises because
|
|
156
|
+
type inference needs the chart's data, but we can still recover the
|
|
157
|
+
field from `_kwds['shorthand']`."""
|
|
158
|
+
ch = alt.Y("strain")
|
|
159
|
+
assert _channel_field(ch) == "strain"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_channel_field_explicit_field_kwarg() -> None:
|
|
163
|
+
"""`alt.Y(field='strain', type='nominal')` — the explicit form."""
|
|
164
|
+
ch = alt.Y(field="strain", type="nominal")
|
|
165
|
+
assert _channel_field(ch) == "strain"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_channel_field_after_from_dict_roundtrip() -> None:
|
|
169
|
+
"""After `alt.Chart.from_dict(...)` (used by the CLI / JSON / HTML
|
|
170
|
+
chart loaders), the field is stored as a `FieldName(SchemaBase)`
|
|
171
|
+
wrapper, not a plain `str`. The helper must unwrap it."""
|
|
172
|
+
df = pd.DataFrame({"strain": ["A", "B"], "titer": [1.0, 2.0]})
|
|
173
|
+
chart = alt.Chart(df).mark_circle().encode(x="titer:Q", y=alt.Y("strain:N"))
|
|
174
|
+
roundtripped = alt.Chart.from_dict(chart.to_dict())
|
|
175
|
+
assert _channel_field(roundtripped.encoding.y) == "strain"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_channel_field_aggregate_shorthand() -> None:
|
|
179
|
+
"""`alt.X('mean(titer):Q')` aggregate-wrapped shorthand."""
|
|
180
|
+
ch = alt.X("mean(titer):Q")
|
|
181
|
+
assert _channel_field(ch) == "titer"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_channel_field_value_only_returns_none() -> None:
|
|
185
|
+
"""A `value=` constant encoding has no field — legitimate None,
|
|
186
|
+
not an error."""
|
|
187
|
+
ch = alt.Y(value=5)
|
|
188
|
+
assert _channel_field(ch) is None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_channel_field_unbalanced_aggregate_raises() -> None:
|
|
192
|
+
"""A shorthand string we can't parse must raise rather than silently
|
|
193
|
+
return None — silent fallthrough is what hid the original bug."""
|
|
194
|
+
ch = alt.Y("strain")
|
|
195
|
+
ch._kwds["shorthand"] = "mean(titer:Q"
|
|
196
|
+
with pytest.raises(ValueError, match="unbalanced aggregate"):
|
|
197
|
+
_channel_field(ch)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_channel_field_non_kwds_object_raises() -> None:
|
|
201
|
+
"""A non-altair object accidentally passed in must raise, not return
|
|
202
|
+
None — same fail-fast principle."""
|
|
203
|
+
|
|
204
|
+
class NotAChannel:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
with pytest.raises(ValueError, match="no `_kwds` mapping"):
|
|
208
|
+
_channel_field(NotAChannel())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Connection order on `mark_line` / `mark_trail` / `mark_area` follows tip order.
|
|
2
|
+
|
|
3
|
+
Regression: when the user chart had `mark_line` with strain on a discrete
|
|
4
|
+
axis, lines connected points in tip order most of the time — but adding an
|
|
5
|
+
explicit categorical color-scale `domain` shifted Vega-Lite's default
|
|
6
|
+
connection-order heuristic away from the strain-axis `sort`, causing lines
|
|
7
|
+
to crisscross. The package now sets the `order` channel to a per-row
|
|
8
|
+
tip-rank field on these marks (unless the user has set their own `order`),
|
|
9
|
+
pinning rule 1 of the Vega-Lite fallback chain.
|
|
10
|
+
|
|
11
|
+
The tip-rank is computed via a `calculate` transform that runs `indexof`
|
|
12
|
+
against the tip-names list — Vega-Lite's `order` channel `sort` only
|
|
13
|
+
accepts ascending/descending, so a derived quantitative field is the
|
|
14
|
+
direct way to express custom ordering.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import altair as alt
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
import tree_annotated_plot
|
|
26
|
+
from tree_annotated_plot._plot import _TIP_ORDER_RANK_FIELD
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _synthetic_auspice() -> dict:
|
|
30
|
+
"""4-tip Auspice tree; tip layout order is [A, B, C, D]."""
|
|
31
|
+
return {
|
|
32
|
+
"version": "v2",
|
|
33
|
+
"meta": {},
|
|
34
|
+
"tree": {
|
|
35
|
+
"name": "ROOT",
|
|
36
|
+
"node_attrs": {"div": 0.0},
|
|
37
|
+
"children": [
|
|
38
|
+
{
|
|
39
|
+
"name": "int1",
|
|
40
|
+
"node_attrs": {"div": 0.01},
|
|
41
|
+
"children": [
|
|
42
|
+
{"name": "A", "node_attrs": {"div": 0.04}},
|
|
43
|
+
{"name": "B", "node_attrs": {"div": 0.05}},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "int2",
|
|
48
|
+
"node_attrs": {"div": 0.02},
|
|
49
|
+
"children": [
|
|
50
|
+
{"name": "C", "node_attrs": {"div": 0.03}},
|
|
51
|
+
{"name": "D", "node_attrs": {"div": 0.06}},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
TIP_ORDER = ["A", "B", "C", "D"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _df() -> pd.DataFrame:
|
|
63
|
+
return pd.DataFrame(
|
|
64
|
+
[
|
|
65
|
+
{"strain": s, "serum": serum, "titer": v}
|
|
66
|
+
for s, serum, v in [
|
|
67
|
+
("A", "s1", 100),
|
|
68
|
+
("B", "s1", 200),
|
|
69
|
+
("C", "s1", 400),
|
|
70
|
+
("D", "s1", 800),
|
|
71
|
+
("A", "s2", 150),
|
|
72
|
+
("B", "s2", 250),
|
|
73
|
+
("C", "s2", 350),
|
|
74
|
+
("D", "s2", 700),
|
|
75
|
+
]
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _plot_user_panel(chart: alt.TopLevelMixin) -> dict:
|
|
81
|
+
"""Run plot() with default settings and return the user-panel spec dict."""
|
|
82
|
+
out = tree_annotated_plot.plot(
|
|
83
|
+
_synthetic_auspice(),
|
|
84
|
+
chart,
|
|
85
|
+
chart_strain_field="strain",
|
|
86
|
+
tree_strain_field="name",
|
|
87
|
+
branch_length="div",
|
|
88
|
+
)
|
|
89
|
+
# Default tree_location for y-axis strain is "left" — user panel is hconcat[1].
|
|
90
|
+
return out.to_dict()["hconcat"][1]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_order_encodings(spec: Any) -> list[dict]:
|
|
94
|
+
"""Walk a spec dict and return every `encoding.order` dict found."""
|
|
95
|
+
found: list[dict] = []
|
|
96
|
+
|
|
97
|
+
def walk(node: Any) -> None:
|
|
98
|
+
if isinstance(node, dict):
|
|
99
|
+
enc = node.get("encoding")
|
|
100
|
+
if isinstance(enc, dict) and "order" in enc:
|
|
101
|
+
found.append(enc["order"])
|
|
102
|
+
for k, v in node.items():
|
|
103
|
+
if k != "encoding":
|
|
104
|
+
walk(v)
|
|
105
|
+
elif isinstance(node, list):
|
|
106
|
+
for item in node:
|
|
107
|
+
walk(item)
|
|
108
|
+
|
|
109
|
+
walk(spec)
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _find_tip_rank_transforms(spec: Any) -> list[dict]:
|
|
114
|
+
"""Walk a spec dict and return every calculate transform that computes
|
|
115
|
+
the package's tip-rank derived field."""
|
|
116
|
+
found: list[dict] = []
|
|
117
|
+
|
|
118
|
+
def walk(node: Any) -> None:
|
|
119
|
+
if isinstance(node, dict):
|
|
120
|
+
transforms = node.get("transform")
|
|
121
|
+
if isinstance(transforms, list):
|
|
122
|
+
for t in transforms:
|
|
123
|
+
if isinstance(t, dict) and t.get("as") == _TIP_ORDER_RANK_FIELD:
|
|
124
|
+
found.append(t)
|
|
125
|
+
for v in node.values():
|
|
126
|
+
walk(v)
|
|
127
|
+
elif isinstance(node, list):
|
|
128
|
+
for item in node:
|
|
129
|
+
walk(item)
|
|
130
|
+
|
|
131
|
+
walk(spec)
|
|
132
|
+
return found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _assert_tip_rank_wired(spec: dict) -> None:
|
|
136
|
+
"""Spec contains both an order-channel pointing at the tip-rank field
|
|
137
|
+
and a calculate transform that derives that field from the tip names."""
|
|
138
|
+
orders = _find_order_encodings(spec)
|
|
139
|
+
assert len(orders) == 1, f"expected one order encoding, got {orders}"
|
|
140
|
+
assert orders[0]["field"] == _TIP_ORDER_RANK_FIELD
|
|
141
|
+
assert orders[0]["type"] == "quantitative"
|
|
142
|
+
transforms = _find_tip_rank_transforms(spec)
|
|
143
|
+
assert len(transforms) == 1, f"expected one tip-rank transform, got {transforms}"
|
|
144
|
+
expr = transforms[0]["calculate"]
|
|
145
|
+
assert "indexof" in expr
|
|
146
|
+
# The expression embeds the tip names list verbatim.
|
|
147
|
+
for tip in TIP_ORDER:
|
|
148
|
+
assert f'"{tip}"' in expr, f"tip {tip!r} not in calculate expression {expr!r}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_mark_line_with_color_domain_gets_order_in_tip_order():
|
|
152
|
+
"""The original bug: explicit color-scale `domain` no longer crisscrosses lines."""
|
|
153
|
+
chart = (
|
|
154
|
+
alt.Chart(_df())
|
|
155
|
+
.mark_line(point=True)
|
|
156
|
+
.encode(
|
|
157
|
+
x=alt.X("titer:Q", scale=alt.Scale(type="log")),
|
|
158
|
+
y=alt.Y("strain:N"),
|
|
159
|
+
color=alt.Color(
|
|
160
|
+
"serum:N",
|
|
161
|
+
scale=alt.Scale(domain=["s1", "s2"], range=["red", "blue"]),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
.properties(width=300, height=200)
|
|
165
|
+
)
|
|
166
|
+
_assert_tip_rank_wired(_plot_user_panel(chart))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_user_supplied_order_is_preserved():
|
|
170
|
+
"""User-supplied `order` always wins; the package never overwrites it."""
|
|
171
|
+
chart = (
|
|
172
|
+
alt.Chart(_df())
|
|
173
|
+
.mark_line()
|
|
174
|
+
.encode(
|
|
175
|
+
x=alt.X("titer:Q"),
|
|
176
|
+
y=alt.Y("strain:N"),
|
|
177
|
+
color="serum:N",
|
|
178
|
+
order=alt.Order("titer:Q", sort="ascending"),
|
|
179
|
+
)
|
|
180
|
+
.properties(width=300, height=200)
|
|
181
|
+
)
|
|
182
|
+
spec = _plot_user_panel(chart)
|
|
183
|
+
orders = _find_order_encodings(spec)
|
|
184
|
+
assert len(orders) == 1
|
|
185
|
+
assert orders[0]["field"] == "titer"
|
|
186
|
+
# The package did not attach its tip-rank transform either.
|
|
187
|
+
assert _find_tip_rank_transforms(spec) == []
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.parametrize("mark_method", ["mark_line", "mark_area", "mark_trail"])
|
|
191
|
+
def test_connection_order_marks_get_order_in_tip_order(mark_method: str):
|
|
192
|
+
"""All three connection-order marks (line / area / trail) get `order` injected."""
|
|
193
|
+
base = alt.Chart(_df()).encode(
|
|
194
|
+
x=alt.X("titer:Q"),
|
|
195
|
+
y=alt.Y("strain:N"),
|
|
196
|
+
color="serum:N",
|
|
197
|
+
)
|
|
198
|
+
chart = getattr(base, mark_method)().properties(width=300, height=200)
|
|
199
|
+
_assert_tip_rank_wired(_plot_user_panel(chart))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_layered_chart_only_line_layer_gets_order():
|
|
203
|
+
"""Mixed-mark layers: line layer gets `order`, circle layer is untouched."""
|
|
204
|
+
base = alt.Chart(_df()).encode(
|
|
205
|
+
x=alt.X("titer:Q"),
|
|
206
|
+
y=alt.Y("strain:N"),
|
|
207
|
+
color="serum:N",
|
|
208
|
+
)
|
|
209
|
+
layered = (base.mark_line() + base.mark_circle()).properties(width=300, height=200)
|
|
210
|
+
_assert_tip_rank_wired(_plot_user_panel(layered))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_no_connection_order_marks_means_no_order_injected():
|
|
214
|
+
"""A chart with only point/circle/etc. marks is a no-op for the walker."""
|
|
215
|
+
chart = (
|
|
216
|
+
alt.Chart(_df())
|
|
217
|
+
.mark_circle()
|
|
218
|
+
.encode(
|
|
219
|
+
x=alt.X("titer:Q"),
|
|
220
|
+
y=alt.Y("strain:N"),
|
|
221
|
+
color="serum:N",
|
|
222
|
+
)
|
|
223
|
+
.properties(width=300, height=200)
|
|
224
|
+
)
|
|
225
|
+
spec = _plot_user_panel(chart)
|
|
226
|
+
assert _find_order_encodings(spec) == []
|
|
227
|
+
assert _find_tip_rank_transforms(spec) == []
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_faceted_chart_gets_order_injection():
|
|
231
|
+
"""The walker descends into the inner spec of a faceted chart."""
|
|
232
|
+
df_facet = pd.DataFrame(
|
|
233
|
+
[
|
|
234
|
+
{"strain": s, "serum": serum, "panel": p, "titer": v}
|
|
235
|
+
for s, serum, p, v in [
|
|
236
|
+
("A", "s1", "P1", 100),
|
|
237
|
+
("B", "s1", "P1", 200),
|
|
238
|
+
("C", "s1", "P1", 400),
|
|
239
|
+
("D", "s1", "P1", 800),
|
|
240
|
+
("A", "s1", "P2", 150),
|
|
241
|
+
("B", "s1", "P2", 250),
|
|
242
|
+
("C", "s1", "P2", 350),
|
|
243
|
+
("D", "s1", "P2", 700),
|
|
244
|
+
]
|
|
245
|
+
]
|
|
246
|
+
)
|
|
247
|
+
base = (
|
|
248
|
+
alt.Chart(df_facet)
|
|
249
|
+
.mark_line()
|
|
250
|
+
.encode(
|
|
251
|
+
x=alt.X("titer:Q"),
|
|
252
|
+
y=alt.Y("strain:N"),
|
|
253
|
+
color="serum:N",
|
|
254
|
+
)
|
|
255
|
+
.properties(width=300, height=200)
|
|
256
|
+
)
|
|
257
|
+
faceted = base.facet(facet="panel:N", columns=2)
|
|
258
|
+
_assert_tip_rank_wired(_plot_user_panel(faceted))
|
|
@@ -110,6 +110,73 @@ def test_plot_overrides_y_sort_to_tree_tip_order():
|
|
|
110
110
|
assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def test_plot_overrides_sort_for_untyped_shorthand_y():
|
|
114
|
+
"""Regression: `alt.Y('strain')` (no `:N` suffix) used to silently
|
|
115
|
+
skip the sort override because `_channel_field` called `to_dict()`
|
|
116
|
+
on the bare channel, which raises without a chart-data context."""
|
|
117
|
+
chart = (
|
|
118
|
+
alt.Chart(_synthetic_df())
|
|
119
|
+
.mark_line(point=True)
|
|
120
|
+
.encode(
|
|
121
|
+
x=alt.X("titer:Q", scale=alt.Scale(type="log")),
|
|
122
|
+
y=alt.Y("strain"),
|
|
123
|
+
color="serum:N",
|
|
124
|
+
)
|
|
125
|
+
.properties(width=300, height=200)
|
|
126
|
+
)
|
|
127
|
+
out = tree_annotated_plot.plot(
|
|
128
|
+
_synthetic_auspice(),
|
|
129
|
+
chart,
|
|
130
|
+
chart_strain_field="strain",
|
|
131
|
+
tree_strain_field="name",
|
|
132
|
+
branch_length="div",
|
|
133
|
+
)
|
|
134
|
+
user_panel_spec = out.hconcat[1].to_dict()
|
|
135
|
+
assert user_panel_spec["encoding"]["y"]["sort"] == ["A", "B", "C", "D"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_plot_overrides_sort_for_untyped_shorthand_x():
|
|
139
|
+
"""Same regression on the horizontal layout."""
|
|
140
|
+
chart = (
|
|
141
|
+
alt.Chart(_synthetic_df())
|
|
142
|
+
.mark_line(point=True)
|
|
143
|
+
.encode(
|
|
144
|
+
x=alt.X("strain"),
|
|
145
|
+
y=alt.Y("titer:Q", scale=alt.Scale(type="log")),
|
|
146
|
+
color="serum:N",
|
|
147
|
+
)
|
|
148
|
+
.properties(width=200, height=300)
|
|
149
|
+
)
|
|
150
|
+
out = tree_annotated_plot.plot(
|
|
151
|
+
_synthetic_auspice(),
|
|
152
|
+
chart,
|
|
153
|
+
chart_strain_field="strain",
|
|
154
|
+
tree_strain_field="name",
|
|
155
|
+
branch_length="div",
|
|
156
|
+
)
|
|
157
|
+
# tree_location defaults to "bottom" for x-axis strain → vconcat(user, tree)
|
|
158
|
+
user_panel_spec = out.vconcat[0].to_dict()
|
|
159
|
+
assert user_panel_spec["encoding"]["x"]["sort"] == ["A", "B", "C", "D"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_plot_consistency_tripwire_fires_when_live_walker_misses(monkeypatch):
|
|
163
|
+
"""If `_channel_field` ever reverts to silently failing, the spec walker
|
|
164
|
+
finds a strain encoding the live walker doesn't, and the count-based
|
|
165
|
+
consistency check raises rather than letting a wrong-ordered chart
|
|
166
|
+
render."""
|
|
167
|
+
from tree_annotated_plot import _plot
|
|
168
|
+
|
|
169
|
+
monkeypatch.setattr(_plot, "_channel_field", lambda ch: None)
|
|
170
|
+
with pytest.raises(RuntimeError, match="internal consistency check failed"):
|
|
171
|
+
tree_annotated_plot.plot(
|
|
172
|
+
_synthetic_auspice(),
|
|
173
|
+
_synthetic_chart(),
|
|
174
|
+
chart_strain_field="strain",
|
|
175
|
+
tree_strain_field="name",
|
|
176
|
+
branch_length="div",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
113
180
|
def test_plot_strain_mismatch_raises():
|
|
114
181
|
bad = _synthetic_chart()
|
|
115
182
|
bad.data = bad.data.replace({"strain": {"D": "X"}})
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|