tree-annotated-plot 0.2.1__py3-none-any.whl → 0.3.0__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.
@@ -109,9 +109,14 @@ class PlotConfig:
109
109
  "When off (default), tree tips not present in the chart's strain "
110
110
  "set are a fatal error. When on, those tips (and any internal "
111
111
  "nodes whose subtrees become empty) are dropped before drawing, "
112
- "with single-child internals collapsed into their kept child. "
113
- "Chart strains not present in the tree are *always* fatal "
114
- "regardless of this flag — pruning would silently lose plot data.",
112
+ "with single-child internals collapsed into their kept child.",
113
+ ] = False
114
+
115
+ prune_chart_to_tree: Annotated[
116
+ bool,
117
+ "When off (default), chart strains not present in the tree are a "
118
+ "fatal error. When on, chart rows whose `chart_strain_field` "
119
+ "value isn't a tree tip are filtered out before drawing.",
115
120
  ] = False
116
121
 
117
122
  strict_version: Annotated[
@@ -55,6 +55,7 @@ def plot(
55
55
  scale_bar: bool = False,
56
56
  branch_length_units: str | None = None,
57
57
  prune_tree_to_chart: bool = False,
58
+ prune_chart_to_tree: bool = False,
58
59
  strict_version: bool = True,
59
60
  connect_leader_to_label: bool = False,
60
61
  strain_label_font_size: float = 10.0,
@@ -82,6 +83,7 @@ def plot(
82
83
  scale_bar=scale_bar,
83
84
  branch_length_units=branch_length_units,
84
85
  prune_tree_to_chart=prune_tree_to_chart,
86
+ prune_chart_to_tree=prune_chart_to_tree,
85
87
  strict_version=strict_version,
86
88
  connect_leader_to_label=connect_leader_to_label,
87
89
  strain_label_font_size=strain_label_font_size,
@@ -168,12 +170,33 @@ def _build(
168
170
 
169
171
  chart_strains = _extract_chart_strains(spec, axis_hits, config.chart_strain_field)
170
172
 
173
+ if config.prune_chart_to_tree and (set(chart_strains) - set(tip_names)):
174
+ _prune_chart_spec_to_strains(
175
+ spec,
176
+ chart_strain_field=config.chart_strain_field,
177
+ keep_strains=set(tip_names),
178
+ )
179
+ chart = alt.Chart.from_dict(spec)
180
+ chart_strains = _extract_chart_strains(
181
+ spec, axis_hits, config.chart_strain_field
182
+ )
183
+ if not chart_strains:
184
+ raise ValueError(
185
+ "prune_chart_to_tree=True dropped every chart row: no "
186
+ "chart strain matched any tree tip under "
187
+ f"chart_strain_field={config.chart_strain_field!r} / "
188
+ f"tree_strain_field={config.tree_strain_field!r}. Pruning "
189
+ "is meant for charts that bundle a superset of strains; "
190
+ "an empty intersection suggests a wrong field choice."
191
+ )
192
+
171
193
  _reconcile_tips_and_strains(
172
194
  tree_strains=tip_names,
173
195
  chart_strains=chart_strains,
174
196
  chart_strain_field=config.chart_strain_field,
175
197
  tree_strain_field=config.tree_strain_field,
176
198
  prune_tree_to_chart=config.prune_tree_to_chart,
199
+ prune_chart_to_tree=config.prune_chart_to_tree,
177
200
  chart_spec=spec,
178
201
  tree_source=tree,
179
202
  )
@@ -233,6 +256,28 @@ def _build(
233
256
  ch.axis = alt.Axis(labels=False, ticks=False, domain=False, title=None)
234
257
  n_hits += 1
235
258
  _check_walker_hits("strain-axis update", n_hits, len(axis_hits), axis)
259
+
260
+ # Pin line/trail/area connection order to tip order via Vega-Lite's
261
+ # `order` channel. Without this, an explicit categorical `color` scale
262
+ # `domain` (or other encoding choices) can shift Vega-Lite's
263
+ # connection-order heuristic away from the strain-axis sort, causing
264
+ # lines to crisscross. The order channel's `sort` only accepts
265
+ # ascending/descending, so we attach a `calculate` transform that
266
+ # computes a per-row tip rank and point `order` at that derived
267
+ # quantitative field. User-supplied `order` always wins.
268
+ rank_expr = (
269
+ f"indexof({json.dumps(list(tip_names))}, "
270
+ f"datum[{json.dumps(config.chart_strain_field)}])"
271
+ )
272
+ for node in _iter_connection_order_nodes(new_chart, config.chart_strain_field):
273
+ enc = _live_attr(node, "encoding")
274
+ if _live_attr(enc, "order") is not None:
275
+ continue
276
+ existing = _live_attr(node, "transform") or []
277
+ node.transform = list(existing) + [
278
+ {"calculate": rank_expr, "as": _TIP_ORDER_RANK_FIELD}
279
+ ]
280
+ enc.order = alt.Order(f"{_TIP_ORDER_RANK_FIELD}:Q")
236
281
  hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
237
282
 
238
283
  combined = _concat_for_location(
@@ -276,22 +321,32 @@ def _concat_for_location(
276
321
  user_chart: alt.TopLevelMixin,
277
322
  location: TreeLocation,
278
323
  ) -> alt.HConcatChart | alt.VConcatChart:
279
- """Concat tree and chart in the order implied by the tree's location."""
324
+ """Concat tree and chart in the order implied by the tree's location.
325
+
326
+ The strain axis is resolved independent so the tree and chart can use
327
+ different scales on that axis (the tree's branch length vs. the chart's
328
+ measurement value), while still sharing the orthogonal strain axis. The
329
+ `color` scale is also resolved independent: when ``color_tree_by`` is set
330
+ the tree panel emits a `color_value:N` color scale with a tree-specific
331
+ domain, and Vega-Lite's default of sharing color across concat views
332
+ would merge it with any color encoding on the user's chart, hiding
333
+ user-chart marks whose color values aren't in the tree's domain.
334
+ """
280
335
  if location == "left":
281
336
  return alt.hconcat(tree_chart, user_chart, spacing=0).resolve_scale(
282
- y="independent"
337
+ y="independent", color="independent"
283
338
  )
284
339
  if location == "right":
285
340
  return alt.hconcat(user_chart, tree_chart, spacing=0).resolve_scale(
286
- y="independent"
341
+ y="independent", color="independent"
287
342
  )
288
343
  if location == "top":
289
344
  return alt.vconcat(tree_chart, user_chart, spacing=0).resolve_scale(
290
- x="independent"
345
+ x="independent", color="independent"
291
346
  )
292
347
  if location == "bottom":
293
348
  return alt.vconcat(user_chart, tree_chart, spacing=0).resolve_scale(
294
- x="independent"
349
+ x="independent", color="independent"
295
350
  )
296
351
  raise ValueError(f"unreachable: tree_location={location!r}")
297
352
 
@@ -609,6 +664,70 @@ def _extract_chart_strains(
609
664
  return _extract_field_values_from_spec_data(spec, chart_strain_field)
610
665
 
611
666
 
667
+ def _prune_chart_spec_to_strains(
668
+ spec: dict, *, chart_strain_field: str, keep_strains: set[str]
669
+ ) -> None:
670
+ """Filter a Vega-Lite spec in place to drop rows outside `keep_strains`.
671
+
672
+ Mutates three kinds of structure:
673
+ - top-level `datasets` entries (each a list of row-dicts).
674
+ - inline `data.values` lists anywhere in the spec tree.
675
+ - explicit `sort` lists on encoding channels bound to
676
+ `chart_strain_field` (any other `sort` is left alone).
677
+
678
+ Rows that don't carry `chart_strain_field` at all are preserved
679
+ (we have no signal to drop them). URL-backed data raises — we
680
+ can't fetch + filter at plot time, mirroring `_extract_chart_strains`.
681
+ """
682
+
683
+ def row_kept(row: Any) -> bool:
684
+ if not isinstance(row, dict):
685
+ return True
686
+ if chart_strain_field not in row:
687
+ return True
688
+ return row[chart_strain_field] in keep_strains
689
+
690
+ datasets = spec.get("datasets") if isinstance(spec, dict) else None
691
+ if isinstance(datasets, dict):
692
+ for name, rows in list(datasets.items()):
693
+ if isinstance(rows, list):
694
+ datasets[name] = [row for row in rows if row_kept(row)]
695
+
696
+ def walk(node: Any) -> None:
697
+ if isinstance(node, dict):
698
+ data = node.get("data")
699
+ if isinstance(data, dict):
700
+ if "url" in data:
701
+ raise ValueError(
702
+ f"chart references data via URL ({data['url']!r}); "
703
+ "URL data is not supported, so prune_chart_to_tree "
704
+ "cannot filter it. Materialize the data inline "
705
+ "(via alt.Chart(df) with a pandas DataFrame) before "
706
+ "saving the chart."
707
+ )
708
+ if "values" in data and isinstance(data["values"], list):
709
+ data["values"] = [row for row in data["values"] if row_kept(row)]
710
+ encoding = node.get("encoding")
711
+ if isinstance(encoding, dict):
712
+ for channel in encoding.values():
713
+ if not isinstance(channel, dict):
714
+ continue
715
+ if channel.get("field") != chart_strain_field:
716
+ continue
717
+ sort = channel.get("sort")
718
+ if isinstance(sort, list):
719
+ channel["sort"] = [s for s in sort if s in keep_strains]
720
+ for k, v in node.items():
721
+ if k in ("data", "datasets"):
722
+ continue
723
+ walk(v)
724
+ elif isinstance(node, list):
725
+ for item in node:
726
+ walk(item)
727
+
728
+ walk(spec)
729
+
730
+
612
731
  def _extract_field_values_from_spec_data(spec: dict, field: str) -> list[str]:
613
732
  """Walk spec for inline / named data and return distinct values of `field`.
614
733
 
@@ -676,13 +795,16 @@ def _reconcile_tips_and_strains(
676
795
  chart_strain_field: str,
677
796
  tree_strain_field: str,
678
797
  prune_tree_to_chart: bool,
798
+ prune_chart_to_tree: bool,
679
799
  chart_spec: dict,
680
800
  tree_source: Any,
681
801
  ) -> None:
682
802
  """Verify tree strains and chart strains are reconcilable.
683
803
 
684
804
  Three asymmetries:
685
- - chart strains not in tree → always fatal.
805
+ - chart strains not in tree → fatal unless `prune_chart_to_tree=True`
806
+ (in which case the chart spec has already been pre-filtered upstream
807
+ and this set is expected to be empty by the time we get here).
686
808
  - tree tips not in chart → fatal unless `prune_tree_to_chart=True`.
687
809
  - (duplicate tree_strain_field values across tips → handled by the
688
810
  separate `_check_no_duplicate_tip_strains`.)
@@ -696,7 +818,9 @@ def _reconcile_tips_and_strains(
696
818
  chart_minus_tree = chart_set - tree_set
697
819
  tree_minus_chart = tree_set - chart_set
698
820
 
699
- if not chart_minus_tree and (not tree_minus_chart or prune_tree_to_chart):
821
+ chart_ok = not chart_minus_tree
822
+ tree_ok = not tree_minus_chart or prune_tree_to_chart
823
+ if chart_ok and tree_ok:
700
824
  return
701
825
 
702
826
  hints = _candidate_field_hints(
@@ -716,6 +840,7 @@ def _reconcile_tips_and_strains(
716
840
  chart_minus_tree=chart_minus_tree,
717
841
  tree_minus_chart=tree_minus_chart,
718
842
  prune_tree_to_chart=prune_tree_to_chart,
843
+ prune_chart_to_tree=prune_chart_to_tree,
719
844
  hints=hints,
720
845
  )
721
846
  )
@@ -730,14 +855,16 @@ def _format_strain_mismatch(
730
855
  chart_minus_tree: set[str],
731
856
  tree_minus_chart: set[str],
732
857
  prune_tree_to_chart: bool,
858
+ prune_chart_to_tree: bool,
733
859
  hints: list[str],
734
860
  ) -> str:
735
861
  parts: list[str] = []
736
- if chart_minus_tree:
862
+ if chart_minus_tree and not prune_chart_to_tree:
737
863
  parts.append(
738
864
  f"{len(chart_minus_tree)} chart strain(s) are not present in the "
739
- "tree (these would be silently dropped if we pruned, so this is "
740
- "always fatal)."
865
+ "tree. Pass `prune_chart_to_tree=True` to drop the offending "
866
+ "chart rows automatically (use with care — this discards plot "
867
+ "data)."
741
868
  )
742
869
  if tree_minus_chart and not prune_tree_to_chart:
743
870
  parts.append(
@@ -751,7 +878,7 @@ def _format_strain_mismatch(
751
878
  )
752
879
  parts.append("Sample chart_strain_field values: " f"{sorted(chart_strains)[:5]}")
753
880
  parts.append("Sample tree_strain_field values: " f"{sorted(tree_strains)[:5]}")
754
- if chart_minus_tree:
881
+ if chart_minus_tree and not prune_chart_to_tree:
755
882
  parts.append(f"Sample chart-only values: {sorted(chart_minus_tree)[:5]}")
756
883
  if tree_minus_chart and not prune_tree_to_chart:
757
884
  parts.append(f"Sample tree-only values: {sorted(tree_minus_chart)[:5]}")
@@ -999,6 +1126,65 @@ def _live_attr(obj: Any, name: str) -> Any:
999
1126
  return v
1000
1127
 
1001
1128
 
1129
+ # Vega-Lite marks whose drawing order along the discrete axis is determined
1130
+ # by the order channel (rule 1 of the connection-order fallback chain). For
1131
+ # these marks pinning the order channel to a per-row tip-order rank ensures
1132
+ # the line / trail / area connects strains in tip order regardless of how
1133
+ # Vega-Lite would otherwise resolve the fallback (e.g. when an explicit
1134
+ # color-scale `domain` shifts it away from the y-axis sort).
1135
+ _CONNECTION_ORDER_MARKS = frozenset({"line", "trail", "area"})
1136
+
1137
+ # Derived field name appended to each connection-order mark's transform
1138
+ # pipeline. Chosen to be unlikely to collide with user data; private prefix
1139
+ # makes the intent clear if anyone inspects the rendered spec.
1140
+ _TIP_ORDER_RANK_FIELD = "_tap_strain_order_idx"
1141
+
1142
+
1143
+ def _mark_type(node: Any) -> str | None:
1144
+ """Return the mark type string for a chart node, or None.
1145
+
1146
+ Handles altair's two mark forms: the plain string (e.g. ``"line"`` from
1147
+ ``chart.mark_line()``) and the ``MarkDef`` object (e.g. from
1148
+ ``chart.mark_line(point=True)``). Container nodes (LayerChart,
1149
+ HConcatChart, etc.) have no mark and return None.
1150
+ """
1151
+ mark = _live_attr(node, "mark")
1152
+ if mark is None:
1153
+ return None
1154
+ if isinstance(mark, str):
1155
+ return mark
1156
+ type_ = _live_attr(mark, "type")
1157
+ return type_ if isinstance(type_, str) else None
1158
+
1159
+
1160
+ def _iter_connection_order_nodes(node: Any, chart_strain_field: str) -> Iterator[Any]:
1161
+ """Yield the live chart node for every node whose mark is in
1162
+ ``_CONNECTION_ORDER_MARKS`` and whose ``x``/``y`` strain encoding
1163
+ matches ``chart_strain_field``.
1164
+
1165
+ Mirrors `_iter_strain_axis_channels`'s traversal (hconcat / vconcat /
1166
+ concat / layer / spec descent). Yields the node itself (not just the
1167
+ encoding) so the caller can attach both a ``calculate`` transform and
1168
+ the ``order`` channel.
1169
+ """
1170
+ if _mark_type(node) in _CONNECTION_ORDER_MARKS:
1171
+ enc = _live_attr(node, "encoding")
1172
+ if enc is not None:
1173
+ for channel in ("x", "y"):
1174
+ ch = _live_attr(enc, channel)
1175
+ if ch is not None and _channel_field(ch) == chart_strain_field:
1176
+ yield node
1177
+ break
1178
+ for attr in ("hconcat", "vconcat", "concat", "layer"):
1179
+ sub = _live_attr(node, attr)
1180
+ if isinstance(sub, list):
1181
+ for s in sub:
1182
+ yield from _iter_connection_order_nodes(s, chart_strain_field)
1183
+ spec = _live_attr(node, "spec")
1184
+ if spec is not None:
1185
+ yield from _iter_connection_order_nodes(spec, chart_strain_field)
1186
+
1187
+
1002
1188
  def _pop_toplevel_only_attrs(
1003
1189
  chart: alt.TopLevelMixin,
1004
1190
  ) -> tuple[Any, dict]:
@@ -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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tree-annotated-plot
3
- Version: 0.2.1
3
+ Version: 0.3.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.13
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
@@ -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.13+.
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
@@ -0,0 +1,11 @@
1
+ tree_annotated_plot/__init__.py,sha256=R39gjVnl3K2VjLba6zwtwPGADFmtNOE2UYNk1y9_z6k,351
2
+ tree_annotated_plot/_color.py,sha256=nOZp-KIPy6s-6gBkzW36oHrwDk_SmvVNsMiIJ-dY1Io,26519
3
+ tree_annotated_plot/_config.py,sha256=Xs9XfuPAaIhGuffYbsDOfcJW_f4etpgPW4ombHNuIAg,12630
4
+ tree_annotated_plot/_plot.py,sha256=a-sxcZ83Sp-bc0swwisOlvhV7L6bhJQWeh5kppANMX0,76901
5
+ tree_annotated_plot/_tree.py,sha256=Bj-ABt8aooSwV7wAVTbwFGMmCnTPGOKweUWAdfb7aEI,13563
6
+ tree_annotated_plot/cli.py,sha256=OG6Qmzk5BaHUNrfOopX20FbzBN2ahr_SBxHpaCk6Z5c,9848
7
+ tree_annotated_plot-0.3.0.dist-info/METADATA,sha256=oQGwnxJxdq3g_fx_gfn6mjsskj0RHpBcMw2pGcJNZPo,11455
8
+ tree_annotated_plot-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ tree_annotated_plot-0.3.0.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
10
+ tree_annotated_plot-0.3.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
11
+ tree_annotated_plot-0.3.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- tree_annotated_plot/__init__.py,sha256=R39gjVnl3K2VjLba6zwtwPGADFmtNOE2UYNk1y9_z6k,351
2
- tree_annotated_plot/_color.py,sha256=nOZp-KIPy6s-6gBkzW36oHrwDk_SmvVNsMiIJ-dY1Io,26519
3
- tree_annotated_plot/_config.py,sha256=XwokRKY-ytNXelxwuapRKedTBaEedDi_erUp3su7zUs,12498
4
- tree_annotated_plot/_plot.py,sha256=l0GP66kLsVl1UjBmHIpnFcRGbFfk6qztxgi8ebYyxnM,68334
5
- tree_annotated_plot/_tree.py,sha256=Bj-ABt8aooSwV7wAVTbwFGMmCnTPGOKweUWAdfb7aEI,13563
6
- tree_annotated_plot/cli.py,sha256=yXI5fcLoIoLf6EjgPLmIQIAzDfkVoS5mSZ26_HSHVmk,9786
7
- tree_annotated_plot-0.2.1.dist-info/METADATA,sha256=EWRbJmbr3W_Rrm3-ggkTB_mhA06czQDaBO91RM_NdBQ,11353
8
- tree_annotated_plot-0.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- tree_annotated_plot-0.2.1.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
10
- tree_annotated_plot-0.2.1.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
11
- tree_annotated_plot-0.2.1.dist-info/RECORD,,