tree-annotated-plot 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

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.
@@ -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 = _apply_tree_order_to_chart_object(
227
- chart, config.chart_strain_field, tip_names
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
- if config.connect_leader_to_label:
230
- _suppress_chart_strain_axis(new_chart, config.chart_strain_field)
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 _apply_tree_order_to_chart_object(
920
- chart: alt.TopLevelMixin, chart_strain_field: str, sort_order: list[str]
921
- ) -> alt.TopLevelMixin:
922
- """Return a deepcopy of the user's chart with `sort=sort_order` applied
923
- to every axis encoding that references `chart_strain_field`.
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.sort = list(sort_order)
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
- _walk_and_apply_sort(s, chart_strain_field, sort_order)
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
- _walk_and_apply_sort(spec, chart_strain_field, sort_order)
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
- `ch.field` returns altair's `_PropertySetter` (used for fluent chaining),
1048
- not the stored field name. The stored value is reachable via `to_dict()`.
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
- to_dict = getattr(ch, "to_dict", None)
1051
- if not callable(to_dict):
1052
- return None
1053
- try:
1054
- d = to_dict()
1055
- except Exception:
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(d, dict):
1058
- f = d.get("field")
1059
- if isinstance(f, str):
1060
- return f
1061
- return None
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tree-annotated-plot
3
- Version: 0.2.0
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
@@ -1,11 +1,11 @@
1
1
  tree_annotated_plot/__init__.py,sha256=R39gjVnl3K2VjLba6zwtwPGADFmtNOE2UYNk1y9_z6k,351
2
2
  tree_annotated_plot/_color.py,sha256=nOZp-KIPy6s-6gBkzW36oHrwDk_SmvVNsMiIJ-dY1Io,26519
3
3
  tree_annotated_plot/_config.py,sha256=XwokRKY-ytNXelxwuapRKedTBaEedDi_erUp3su7zUs,12498
4
- tree_annotated_plot/_plot.py,sha256=T49hL5giVsTJgvA6SuA7fs7M1da48NSe7unQf7zRVT8,65397
4
+ tree_annotated_plot/_plot.py,sha256=GCdcPyfTlem9YTlPkgdkJGPb5g4w2Vt0fevJcHEo9HI,72720
5
5
  tree_annotated_plot/_tree.py,sha256=Bj-ABt8aooSwV7wAVTbwFGMmCnTPGOKweUWAdfb7aEI,13563
6
6
  tree_annotated_plot/cli.py,sha256=yXI5fcLoIoLf6EjgPLmIQIAzDfkVoS5mSZ26_HSHVmk,9786
7
- tree_annotated_plot-0.2.0.dist-info/METADATA,sha256=4Ib5mqP-YUKH6JhdnMwnzoY__0MwDnArMIB_mb75V1g,11353
8
- tree_annotated_plot-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- tree_annotated_plot-0.2.0.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
10
- tree_annotated_plot-0.2.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
11
- tree_annotated_plot-0.2.0.dist-info/RECORD,,
7
+ tree_annotated_plot-0.2.2.dist-info/METADATA,sha256=8yAZMK69Tos2TmV-_Gb359Xm6OlmC8Elzzgzdp3yM04,11353
8
+ tree_annotated_plot-0.2.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ tree_annotated_plot-0.2.2.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
10
+ tree_annotated_plot-0.2.2.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
11
+ tree_annotated_plot-0.2.2.dist-info/RECORD,,