tree-annotated-plot 0.2.1__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.
Files changed (43) hide show
  1. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/CHANGELOG.md +19 -0
  2. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/PKG-INFO +1 -1
  3. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/docs/examples.md +13 -4
  4. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/pyproject.toml +1 -1
  5. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/scripts/generate_docs_assets.py +6 -0
  6. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_plot.py +96 -5
  7. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_color_tree.py +34 -0
  8. tree_annotated_plot-0.2.2/tests/test_line_order.py +258 -0
  9. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/.github/workflows/docs.yml +0 -0
  10. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/.github/workflows/release.yml +0 -0
  11. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/.github/workflows/tests.yml +0 -0
  12. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/.gitignore +0 -0
  13. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/CLAUDE.md +0 -0
  14. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/LICENSE +0 -0
  15. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/README.md +0 -0
  16. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/docs/cli.md +0 -0
  17. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/docs/index.md +0 -0
  18. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/docs/python-api.md +0 -0
  19. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/examples/fetch_auspice_data.py +0 -0
  20. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
  21. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/examples/synthetic_example.py +0 -0
  22. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/mkdocs.yml +0 -0
  23. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/scripts/build_docs.sh +0 -0
  24. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/scripts/check.sh +0 -0
  25. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/__init__.py +0 -0
  26. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_color.py +0 -0
  27. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_config.py +0 -0
  28. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/_tree.py +0 -0
  29. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/src/tree_annotated_plot/cli.py +0 -0
  30. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_appearance_tuning.py +0 -0
  31. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_branch_length.py +0 -0
  32. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_chart_loader.py +0 -0
  33. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_cli.py +0 -0
  34. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_examples_run.py +0 -0
  35. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_introspection.py +0 -0
  36. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_label_connection.py +0 -0
  37. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_plot.py +0 -0
  38. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_plot_config.py +0 -0
  39. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_real_data.py +0 -0
  40. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_reconciliation.py +0 -0
  41. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_scale_bar.py +0 -0
  42. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_style.py +0 -0
  43. {tree_annotated_plot-0.2.1 → tree_annotated_plot-0.2.2}/tests/test_tree_location.py +0 -0
@@ -5,6 +5,25 @@ 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
+
8
27
  ## [0.2.1] - 2026-05-06
9
28
 
10
29
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tree-annotated-plot
3
- Version: 0.2.1
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 and adds a categorical legend
109
- below the plot. See "Color the tree" below for the full set of options.
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, and the legend is drawn at the bottom of the
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tree-annotated-plot"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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
 
@@ -233,6 +233,28 @@ def _build(
233
233
  ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
234
234
  n_hits += 1
235
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)}])"
248
+ )
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")
236
258
  hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
237
259
 
238
260
  combined = _concat_for_location(
@@ -276,22 +298,32 @@ def _concat_for_location(
276
298
  user_chart: alt.TopLevelMixin,
277
299
  location: TreeLocation,
278
300
  ) -> alt.HConcatChart | alt.VConcatChart:
279
- """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
+ """
280
312
  if location == "left":
281
313
  return alt.hconcat(tree_chart, user_chart, spacing=0).resolve_scale(
282
- y="independent"
314
+ y="independent", color="independent"
283
315
  )
284
316
  if location == "right":
285
317
  return alt.hconcat(user_chart, tree_chart, spacing=0).resolve_scale(
286
- y="independent"
318
+ y="independent", color="independent"
287
319
  )
288
320
  if location == "top":
289
321
  return alt.vconcat(tree_chart, user_chart, spacing=0).resolve_scale(
290
- x="independent"
322
+ x="independent", color="independent"
291
323
  )
292
324
  if location == "bottom":
293
325
  return alt.vconcat(user_chart, tree_chart, spacing=0).resolve_scale(
294
- x="independent"
326
+ x="independent", color="independent"
295
327
  )
296
328
  raise ValueError(f"unreachable: tree_location={location!r}")
297
329
 
@@ -999,6 +1031,65 @@ def _live_attr(obj: Any, name: str) -> Any:
999
1031
  return v
1000
1032
 
1001
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
+
1002
1093
  def _pop_toplevel_only_attrs(
1003
1094
  chart: alt.TopLevelMixin,
1004
1095
  ) -> tuple[Any, dict]:
@@ -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
  # -----------------------------------------------------------------------------
@@ -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))