tree-annotated-plot 0.0.1__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.
@@ -0,0 +1,1337 @@
1
+ """Build the tree-annotated chart by introspecting and extending a user's Altair chart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import math
8
+ import re
9
+ import warnings
10
+ from html.parser import HTMLParser
11
+ from pathlib import Path
12
+ from typing import Any, Literal
13
+
14
+ import altair as alt
15
+ import pandas as pd
16
+
17
+ from . import _config, _tree
18
+ from ._config import PlotConfig, TreeLocation
19
+
20
+ # Accepted chart input forms for the public `plot` function.
21
+ ChartInput = alt.TopLevelMixin | str | Path | dict
22
+
23
+ # Type for an axis hit found by _find_strain_encoding: (path, encoding_dict, channel).
24
+ # `path` is a tuple of dict keys / list indices that locates `encoding_dict`
25
+ # inside the spec, ending with ("encoding", channel).
26
+ _AxisHit = tuple[tuple, dict, str]
27
+
28
+
29
+ def plot(
30
+ tree: str | Path | dict | _tree.TreeNode,
31
+ chart: ChartInput,
32
+ *,
33
+ chart_strain_field: str,
34
+ tree_strain_field: str,
35
+ branch_length: Literal["div", "num_date"],
36
+ tree_size: int = 100,
37
+ tree_location: TreeLocation | None = None,
38
+ tree_line_width: float = 1.5,
39
+ tree_node_size: float = 28,
40
+ leader_line_width: float = 1.0,
41
+ scale_bar: bool = False,
42
+ branch_length_units: str | None = None,
43
+ prune_tree_to_chart: bool = False,
44
+ strict_version: bool = True,
45
+ ) -> alt.HConcatChart | alt.VConcatChart:
46
+ """Return an Altair chart with a phylogenetic tree drawn alongside `chart`."""
47
+ return _build(
48
+ tree,
49
+ chart,
50
+ PlotConfig(
51
+ chart_strain_field=chart_strain_field,
52
+ tree_strain_field=tree_strain_field,
53
+ branch_length=branch_length,
54
+ tree_size=tree_size,
55
+ tree_location=tree_location,
56
+ tree_line_width=tree_line_width,
57
+ tree_node_size=tree_node_size,
58
+ leader_line_width=leader_line_width,
59
+ scale_bar=scale_bar,
60
+ branch_length_units=branch_length_units,
61
+ prune_tree_to_chart=prune_tree_to_chart,
62
+ strict_version=strict_version,
63
+ ),
64
+ )
65
+
66
+
67
+ # `plot.__doc__` is assembled from canonical descriptions in `_config`:
68
+ # `PlotConfig`'s `Annotated[T, "<description>"]` metadata for every styling
69
+ # / behavior knob, and `_config.{TREE,CHART}_DESCRIPTION` for the two
70
+ # data-input parameters. The CLI's `--help` text reads from the same two
71
+ # sources, so per-parameter text lives in exactly one place.
72
+ _PLOT_DOC_HEADER = (
73
+ "Return an Altair chart with a phylogenetic tree drawn alongside `chart`.\n"
74
+ "\n"
75
+ "The chart's strain-axis sort is overridden to match the tree's tip order\n"
76
+ "(the headline behavior of this package).\n"
77
+ "\n"
78
+ "Parameters\n"
79
+ "----------\n"
80
+ + _config._render_data_param("tree", _config.TREE_DESCRIPTION)
81
+ + "\n"
82
+ + _config._render_data_param("chart", _config.CHART_DESCRIPTION)
83
+ + "\n"
84
+ )
85
+
86
+ _PLOT_DOC_FOOTER = """
87
+
88
+ Returns
89
+ -------
90
+ altair.HConcatChart | altair.VConcatChart
91
+ For vertical layout (chart strain on `y`), an `HConcatChart` with the
92
+ tree on the left and the user's chart on the right. For horizontal
93
+ layout (chart strain on `x`), a `VConcatChart` with the tree on top
94
+ and the chart below.
95
+ """
96
+
97
+
98
+ plot.__doc__ = (
99
+ _PLOT_DOC_HEADER
100
+ + _config._render_numpy_params(_config.PARAM_DOC_EXTRAS)
101
+ + _PLOT_DOC_FOOTER
102
+ )
103
+
104
+
105
+ def _build(
106
+ tree: str | Path | dict | _tree.TreeNode,
107
+ chart: ChartInput,
108
+ config: PlotConfig,
109
+ ) -> alt.HConcatChart | alt.VConcatChart:
110
+ """Shared implementation used by both `plot()` and the CLI.
111
+
112
+ `tree` and `chart` are the data inputs (the things you can't usefully
113
+ set on a config object); `config` carries every styling / behavior
114
+ knob. Both surfaces converge here so they can never disagree.
115
+ """
116
+ root = _ensure_tree(
117
+ tree,
118
+ config.tree_strain_field,
119
+ branch_length=config.branch_length,
120
+ strict_version=config.strict_version,
121
+ )
122
+ tip_list = _tree.layout(root)
123
+ tip_names = [t.name for t in tip_list]
124
+
125
+ _check_no_duplicate_tip_strains(
126
+ tip_names, tree_strain_field=config.tree_strain_field
127
+ )
128
+
129
+ chart = _load_chart(chart, strict_version=config.strict_version)
130
+ spec = chart.to_dict()
131
+
132
+ axis_hits = _find_strain_encoding(spec, config.chart_strain_field)
133
+ axis = axis_hits[0][2]
134
+
135
+ location = _resolve_tree_location(config.tree_location, axis)
136
+
137
+ chart_strains = _extract_chart_strains(spec, axis_hits, config.chart_strain_field)
138
+
139
+ _reconcile_tips_and_strains(
140
+ tree_strains=tip_names,
141
+ chart_strains=chart_strains,
142
+ chart_strain_field=config.chart_strain_field,
143
+ tree_strain_field=config.tree_strain_field,
144
+ prune_tree_to_chart=config.prune_tree_to_chart,
145
+ chart_spec=spec,
146
+ tree_source=tree,
147
+ )
148
+
149
+ if config.prune_tree_to_chart and (set(tip_names) - set(chart_strains)):
150
+ root = _tree._prune_tree_to(root, set(chart_strains))
151
+ tip_list = _tree.layout(root)
152
+ tip_names = [t.name for t in tip_list]
153
+
154
+ strain_dim = _coerce_dim(
155
+ _chart_strain_dim(spec, axis_hits, axis), n_tips=len(tip_names)
156
+ )
157
+ tree_chart = _build_tree_chart(
158
+ root,
159
+ n_tips=len(tip_names),
160
+ tree_size=config.tree_size,
161
+ strain_dim=strain_dim,
162
+ strain_axis=axis,
163
+ tree_location=location,
164
+ tree_line_width=config.tree_line_width,
165
+ tree_node_size=config.tree_node_size,
166
+ leader_line_width=config.leader_line_width,
167
+ scale_bar=config.scale_bar,
168
+ branch_length=config.branch_length,
169
+ branch_length_units=config.branch_length_units,
170
+ )
171
+
172
+ new_chart = _apply_tree_order_to_chart_object(
173
+ chart, config.chart_strain_field, tip_names
174
+ )
175
+ hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
176
+
177
+ combined = _concat_for_location(
178
+ tree_chart=tree_chart, user_chart=new_chart, location=location
179
+ )
180
+ _apply_combined_config(combined, hoisted_config)
181
+ for k, v in hoisted_other.items():
182
+ combined._kwds[k] = v
183
+ return combined
184
+
185
+
186
+ def _resolve_tree_location(
187
+ tree_location: TreeLocation | None, strain_axis: str
188
+ ) -> TreeLocation:
189
+ """Pick the default tree_location matching the strain axis, or validate
190
+ that an explicit value is compatible with the axis."""
191
+ valid_for_y = ("left", "right")
192
+ valid_for_x = ("top", "bottom")
193
+ if tree_location is None:
194
+ return "left" if strain_axis == "y" else "bottom"
195
+ if strain_axis == "y" and tree_location not in valid_for_y:
196
+ raise ValueError(
197
+ f"tree_location={tree_location!r} is incompatible with a "
198
+ "y-encoded strain (vertical layout). The chart's strain "
199
+ "axis is on `y`, so the tree must be alongside it on the "
200
+ f"left or right. Valid values: {valid_for_y!r}."
201
+ )
202
+ if strain_axis == "x" and tree_location not in valid_for_x:
203
+ raise ValueError(
204
+ f"tree_location={tree_location!r} is incompatible with an "
205
+ "x-encoded strain (horizontal layout). The chart's strain "
206
+ "axis is on `x`, so the tree must be above or below it. "
207
+ f"Valid values: {valid_for_x!r}."
208
+ )
209
+ return tree_location
210
+
211
+
212
+ def _concat_for_location(
213
+ *,
214
+ tree_chart: alt.TopLevelMixin,
215
+ user_chart: alt.TopLevelMixin,
216
+ location: TreeLocation,
217
+ ) -> alt.HConcatChart | alt.VConcatChart:
218
+ """Concat tree and chart in the order implied by the tree's location."""
219
+ if location == "left":
220
+ return alt.hconcat(tree_chart, user_chart, spacing=0).resolve_scale(
221
+ y="independent"
222
+ )
223
+ if location == "right":
224
+ return alt.hconcat(user_chart, tree_chart, spacing=0).resolve_scale(
225
+ y="independent"
226
+ )
227
+ if location == "top":
228
+ return alt.vconcat(tree_chart, user_chart, spacing=0).resolve_scale(
229
+ x="independent"
230
+ )
231
+ if location == "bottom":
232
+ return alt.vconcat(user_chart, tree_chart, spacing=0).resolve_scale(
233
+ x="independent"
234
+ )
235
+ raise ValueError(f"unreachable: tree_location={location!r}")
236
+
237
+
238
+ def _ensure_tree(
239
+ tree: str | Path | dict | _tree.TreeNode,
240
+ tree_strain_field: str,
241
+ *,
242
+ branch_length: str,
243
+ strict_version: bool,
244
+ ) -> _tree.TreeNode:
245
+ if isinstance(tree, _tree.TreeNode):
246
+ return tree
247
+ return _tree.load_auspice(
248
+ tree,
249
+ tree_strain_field=tree_strain_field,
250
+ branch_length=branch_length,
251
+ strict_version=strict_version,
252
+ )
253
+
254
+
255
+ # ---------- chart loading (JSON / HTML / dict / live altair) ----------
256
+
257
+
258
+ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLevelMixin:
259
+ """Convert any supported chart input into a live Altair chart.
260
+
261
+ A live `alt.TopLevelMixin` is returned as-is — its `$schema` is
262
+ irrelevant since the constructing altair version is necessarily the
263
+ running altair version. For dict / JSON path / HTML path inputs we run
264
+ the Vega-Lite version check (under `strict_version`), strip altair's
265
+ `params[].views` round-trip annotations, and then dispatch to the right
266
+ chart subclass via `alt.Chart.from_dict(...)`.
267
+ """
268
+ if isinstance(chart_input, alt.TopLevelMixin):
269
+ return chart_input
270
+
271
+ if isinstance(chart_input, dict):
272
+ spec = chart_input
273
+ elif isinstance(chart_input, (str, Path)):
274
+ path = Path(chart_input)
275
+ suffix = path.suffix.lower()
276
+ if suffix == ".json":
277
+ with path.open() as f:
278
+ spec = json.load(f)
279
+ elif suffix == ".html":
280
+ spec = _extract_spec_from_html(path.read_text())
281
+ else:
282
+ raise ValueError(
283
+ f"unsupported chart file extension {suffix!r} for {path}; "
284
+ "expected .json or .html"
285
+ )
286
+ else:
287
+ raise TypeError(
288
+ "chart must be a live Altair chart, a path (str / pathlib.Path) "
289
+ "to a .json or .html file, or a parsed spec dict; got "
290
+ f"{type(chart_input).__name__}"
291
+ )
292
+
293
+ _check_chart_schema_version(spec, strict_version=strict_version)
294
+ spec = copy.deepcopy(spec)
295
+ _strip_params_views(spec)
296
+ # `from_dict` with validate=True dispatches to the right subclass
297
+ # (Chart / LayerChart / FacetChart / HConcatChart / VConcatChart /
298
+ # ConcatChart) based on the spec's shape.
299
+ return alt.Chart.from_dict(spec)
300
+
301
+
302
+ _TARGET_VEGA_LITE_MAJOR = 6
303
+
304
+
305
+ def _check_chart_schema_version(spec: dict, *, strict_version: bool) -> None:
306
+ """Inspect spec['$schema'] and react to mismatched Vega-Lite versions.
307
+
308
+ Three regimes:
309
+ - Vega-Lite < 6 → known incompatible. Raises under strict_version
310
+ (the default); warns under strict_version=False.
311
+ - Vega-Lite = 6 → the version this package was built against.
312
+ Silent.
313
+ - Vega-Lite > 6 → newer than tested. We don't know whether it
314
+ works, but Vega-Lite tends to be backward-compatible so we
315
+ proceed and warn (regardless of strict_version). The user can
316
+ silence the warning via the `warnings` module if needed.
317
+
318
+ Missing or unrecognized $schema URLs always warn and proceed.
319
+ """
320
+ schema = spec.get("$schema")
321
+ if not isinstance(schema, str) or not schema:
322
+ warnings.warn(
323
+ "chart spec has no $schema field; proceeding but the spec may "
324
+ "have been saved by an older altair version with a different "
325
+ "shape than this package expects.",
326
+ stacklevel=3,
327
+ )
328
+ return
329
+ m = re.search(r"vega-lite/v(\d+)", schema)
330
+ if m is None:
331
+ warnings.warn(
332
+ f"chart $schema={schema!r} does not look like a Vega-Lite "
333
+ "schema URL; proceeding but the spec may not be a Vega-Lite "
334
+ "chart.",
335
+ stacklevel=3,
336
+ )
337
+ return
338
+ major = int(m.group(1))
339
+ if major < _TARGET_VEGA_LITE_MAJOR:
340
+ msg = (
341
+ f"chart spec was saved with Vega-Lite {major} (likely altair "
342
+ f"{major - 1}); please re-save it from an altair "
343
+ f"{_TARGET_VEGA_LITE_MAJOR}+ environment with `chart.save(...)`."
344
+ )
345
+ if strict_version:
346
+ raise ValueError(msg)
347
+ warnings.warn(msg, stacklevel=3)
348
+ elif major > _TARGET_VEGA_LITE_MAJOR:
349
+ warnings.warn(
350
+ f"chart spec was saved with Vega-Lite {major}, newer than this "
351
+ f"package targets (Vega-Lite {_TARGET_VEGA_LITE_MAJOR}). Most "
352
+ "things should still work because Vega-Lite is largely "
353
+ "backward-compatible, but if rendering looks wrong please "
354
+ "file an issue.",
355
+ stacklevel=3,
356
+ )
357
+
358
+
359
+ def _strip_params_views(spec: Any) -> None:
360
+ """Recursively delete `views` from every params[] entry.
361
+
362
+ altair's `to_dict()` annotates each selection-param with the views it's
363
+ bound to, but altair's own `from_dict()` validation rejects the field.
364
+ Stripping is safe: the renderer rebinds params to views on its own.
365
+ """
366
+ if isinstance(spec, dict):
367
+ params = spec.get("params")
368
+ if isinstance(params, list):
369
+ for p in params:
370
+ if isinstance(p, dict):
371
+ p.pop("views", None)
372
+ for v in spec.values():
373
+ _strip_params_views(v)
374
+ elif isinstance(spec, list):
375
+ for item in spec:
376
+ _strip_params_views(item)
377
+
378
+
379
+ class _ScriptCollector(HTMLParser):
380
+ """Collect the text content of every <script> tag in an HTML document."""
381
+
382
+ def __init__(self) -> None:
383
+ super().__init__()
384
+ self.scripts: list[str] = []
385
+ self._in_script = False
386
+
387
+ def handle_starttag(self, tag: str, attrs: list) -> None:
388
+ if tag == "script":
389
+ self._in_script = True
390
+
391
+ def handle_endtag(self, tag: str) -> None:
392
+ if tag == "script":
393
+ self._in_script = False
394
+
395
+ def handle_data(self, data: str) -> None:
396
+ if self._in_script:
397
+ self.scripts.append(data)
398
+
399
+
400
+ def _extract_spec_from_html(html: str) -> dict:
401
+ """Pull the Vega-Lite spec dict out of an altair-saved HTML page.
402
+
403
+ altair's default `to_html()` template emits a `var spec = {...};` line
404
+ inside a `<script>` tag whose value is a json.dumps of the spec. We
405
+ locate that line via stdlib `html.parser` (no regex on the document)
406
+ and use `json.JSONDecoder.raw_decode` to do brace-balancing on the
407
+ JSON literal (no regex on the JSON either).
408
+
409
+ Limits: works on the default altair template. If the user passed
410
+ `chart.save(template=...)` with a custom template, this raises with a
411
+ remediation message pointing at JSON. A page with multiple `var spec`
412
+ blocks: we return the first one and document this as a known limit.
413
+ """
414
+ parser = _ScriptCollector()
415
+ parser.feed(html)
416
+ decoder = json.JSONDecoder()
417
+ for script in parser.scripts:
418
+ i = script.find("var spec")
419
+ if i == -1:
420
+ continue
421
+ brace = script.index("{", i)
422
+ spec, _ = decoder.raw_decode(script, brace)
423
+ return spec
424
+ raise ValueError(
425
+ "no `var spec = {...}` block found in the HTML; this is likely a "
426
+ "non-default altair template. Re-save the chart as JSON: "
427
+ "chart.save('foo.json')."
428
+ )
429
+
430
+
431
+ # ---------- chart spec introspection ----------
432
+
433
+
434
+ def _walk_strain_encodings(spec: Any, chart_strain_field: str) -> list[_AxisHit]:
435
+ """Return every encoding referencing `chart_strain_field` in the spec.
436
+
437
+ Walks vconcat/hconcat/concat/facet.spec/layer recursively. Each hit is
438
+ (path, encoding_dict, channel). Path is a tuple of dict keys and list
439
+ indices ending with ("encoding", channel) so the same path on the deepcopy
440
+ locates the same encoding for in-place modification.
441
+ """
442
+ hits: list[_AxisHit] = []
443
+
444
+ def walk(node: Any, path: tuple) -> None:
445
+ if isinstance(node, dict):
446
+ enc = node.get("encoding")
447
+ if isinstance(enc, dict):
448
+ for channel, enc_def in enc.items():
449
+ if (
450
+ isinstance(enc_def, dict)
451
+ and enc_def.get("field") == chart_strain_field
452
+ ):
453
+ hits.append((path + ("encoding", channel), enc_def, channel))
454
+ elif isinstance(enc_def, list):
455
+ # Some channels (notably `tooltip`) carry a list of
456
+ # field definitions. Walk each.
457
+ for i, item in enumerate(enc_def):
458
+ if (
459
+ isinstance(item, dict)
460
+ and item.get("field") == chart_strain_field
461
+ ):
462
+ hits.append(
463
+ (path + ("encoding", channel, i), item, channel)
464
+ )
465
+ for k, v in node.items():
466
+ # `encoding` already inspected; `data`/`datasets`/`config`
467
+ # don't contain encodings.
468
+ if k in ("encoding", "data", "datasets", "config"):
469
+ continue
470
+ walk(v, path + (k,))
471
+ elif isinstance(node, list):
472
+ for i, item in enumerate(node):
473
+ walk(item, path + (i,))
474
+
475
+ walk(spec, ())
476
+ return hits
477
+
478
+
479
+ def _find_strain_encoding(spec: dict, chart_strain_field: str) -> list[_AxisHit]:
480
+ """Walk the spec, validate, and return only the axis (x/y) hits.
481
+
482
+ Validates:
483
+ 1. At least one axis hit exists.
484
+ 2. All axis hits agree on the same channel ('x' or 'y').
485
+ 3. Each axis hit's encoding type is 'nominal' or 'ordinal'.
486
+ Secondary hits (color, tooltip, detail, etc.) are allowed and ignored.
487
+ """
488
+ all_hits = _walk_strain_encodings(spec, chart_strain_field)
489
+
490
+ axis_hits = [h for h in all_hits if h[2] in ("x", "y")]
491
+ secondary_hits = [h for h in all_hits if h[2] not in ("x", "y")]
492
+
493
+ if not axis_hits:
494
+ if secondary_hits:
495
+ channels = sorted({h[2] for h in secondary_hits})
496
+ raise ValueError(
497
+ f"chart_strain_field={chart_strain_field!r} is encoded on "
498
+ f"{channels} but not on 'x' or 'y'; tree alignment requires "
499
+ "the strain to be on an axis channel."
500
+ )
501
+ raise ValueError(
502
+ f"chart_strain_field={chart_strain_field!r} not found in any "
503
+ "encoding of the chart spec. Confirm the field name and that the "
504
+ "chart has an 'x' or 'y' encoding referencing it."
505
+ )
506
+
507
+ axes = {h[2] for h in axis_hits}
508
+ if len(axes) > 1:
509
+ raise ValueError(
510
+ f"chart_strain_field={chart_strain_field!r} is encoded on both "
511
+ f"{sorted(axes)} channels (across layers/panels). The alignment "
512
+ "axis is ambiguous; the chart must encode the strain on exactly "
513
+ "one of x or y."
514
+ )
515
+
516
+ for _, enc, channel in axis_hits:
517
+ t = enc.get("type")
518
+ if t not in ("nominal", "ordinal"):
519
+ raise ValueError(
520
+ f"chart_strain_field={chart_strain_field!r} on the {channel!r} "
521
+ f"channel has type={t!r}; expected 'nominal' or 'ordinal' so "
522
+ "each strain value collapses onto exactly one axis row."
523
+ )
524
+
525
+ return axis_hits
526
+
527
+
528
+ def _extract_chart_strains(
529
+ spec: dict, axis_hits: list[_AxisHit], chart_strain_field: str
530
+ ) -> list[str]:
531
+ """Return the chart's distinct strain values.
532
+
533
+ Prefers an explicit `sort` on the first axis hit. Falls back to walking
534
+ the spec's data (inline values or named datasets). Refuses URL data.
535
+ """
536
+ for _, enc, _ in axis_hits:
537
+ sort = enc.get("sort")
538
+ if isinstance(sort, list) and sort:
539
+ return list(sort)
540
+
541
+ return _extract_field_values_from_spec_data(spec, chart_strain_field)
542
+
543
+
544
+ def _extract_field_values_from_spec_data(spec: dict, field: str) -> list[str]:
545
+ """Walk spec for inline / named data and return distinct values of `field`.
546
+
547
+ Refuses URL data with a clear message: synchronous fetches at plot time
548
+ are out of scope.
549
+ """
550
+ datasets = spec.get("datasets", {}) if isinstance(spec, dict) else {}
551
+ seen: list[Any] = []
552
+
553
+ def walk(node: Any) -> None:
554
+ if isinstance(node, dict):
555
+ data = node.get("data")
556
+ if isinstance(data, dict):
557
+ if "url" in data:
558
+ raise ValueError(
559
+ f"chart references data via URL ({data['url']!r}); "
560
+ "URL data is not supported. Materialize the data inline "
561
+ "(via alt.Chart(df) with a pandas DataFrame) before "
562
+ "saving the chart."
563
+ )
564
+ if "values" in data and isinstance(data["values"], list):
565
+ for row in data["values"]:
566
+ if isinstance(row, dict) and field in row:
567
+ seen.append(row[field])
568
+ elif "name" in data:
569
+ name = data["name"]
570
+ rows = datasets.get(name)
571
+ if isinstance(rows, list):
572
+ for row in rows:
573
+ if isinstance(row, dict) and field in row:
574
+ seen.append(row[field])
575
+ for k, v in node.items():
576
+ if k in ("data", "datasets"):
577
+ continue
578
+ walk(v)
579
+ elif isinstance(node, list):
580
+ for item in node:
581
+ walk(item)
582
+
583
+ walk(spec)
584
+ return list(dict.fromkeys(seen))
585
+
586
+
587
+ def _check_no_duplicate_tip_strains(
588
+ tip_names: list[str], *, tree_strain_field: str
589
+ ) -> None:
590
+ """Tip identifiers must be unique under the chosen `tree_strain_field`."""
591
+ if len(set(tip_names)) == len(tip_names):
592
+ return
593
+ from collections import Counter
594
+
595
+ dups = sorted(s for s, c in Counter(tip_names).items() if c > 1)
596
+ raise ValueError(
597
+ f"tree_strain_field={tree_strain_field!r} resolves to duplicate "
598
+ f"values across tips: {dups[:10]}. Each tip must have a unique "
599
+ "strain identifier; either pick a different tree_strain_field or "
600
+ "fix the underlying tree."
601
+ )
602
+
603
+
604
+ def _reconcile_tips_and_strains(
605
+ *,
606
+ tree_strains: list[str],
607
+ chart_strains: list[str],
608
+ chart_strain_field: str,
609
+ tree_strain_field: str,
610
+ prune_tree_to_chart: bool,
611
+ chart_spec: dict,
612
+ tree_source: Any,
613
+ ) -> None:
614
+ """Verify tree strains and chart strains are reconcilable.
615
+
616
+ Three asymmetries:
617
+ - chart strains not in tree → always fatal.
618
+ - tree tips not in chart → fatal unless `prune_tree_to_chart=True`.
619
+ - (duplicate tree_strain_field values across tips → handled by the
620
+ separate `_check_no_duplicate_tip_strains`.)
621
+
622
+ On any fatal asymmetry, raises `ValueError` with a multi-line message
623
+ that includes sample values from both sides and any candidate-field
624
+ suggestions whose values overlap heavily with the other side.
625
+ """
626
+ tree_set = set(tree_strains)
627
+ chart_set = set(chart_strains)
628
+ chart_minus_tree = chart_set - tree_set
629
+ tree_minus_chart = tree_set - chart_set
630
+
631
+ if not chart_minus_tree and (not tree_minus_chart or prune_tree_to_chart):
632
+ return
633
+
634
+ hints = _candidate_field_hints(
635
+ chart_spec=chart_spec,
636
+ chart_strain_field=chart_strain_field,
637
+ tree_strains=tree_strains,
638
+ tree_source=tree_source,
639
+ tree_strain_field=tree_strain_field,
640
+ chart_strains=chart_strains,
641
+ )
642
+ raise ValueError(
643
+ _format_strain_mismatch(
644
+ chart_strain_field=chart_strain_field,
645
+ tree_strain_field=tree_strain_field,
646
+ chart_strains=chart_strains,
647
+ tree_strains=tree_strains,
648
+ chart_minus_tree=chart_minus_tree,
649
+ tree_minus_chart=tree_minus_chart,
650
+ prune_tree_to_chart=prune_tree_to_chart,
651
+ hints=hints,
652
+ )
653
+ )
654
+
655
+
656
+ def _format_strain_mismatch(
657
+ *,
658
+ chart_strain_field: str,
659
+ tree_strain_field: str,
660
+ chart_strains: list[str],
661
+ tree_strains: list[str],
662
+ chart_minus_tree: set[str],
663
+ tree_minus_chart: set[str],
664
+ prune_tree_to_chart: bool,
665
+ hints: list[str],
666
+ ) -> str:
667
+ parts: list[str] = []
668
+ if chart_minus_tree:
669
+ parts.append(
670
+ f"{len(chart_minus_tree)} chart strain(s) are not present in the "
671
+ "tree (these would be silently dropped if we pruned, so this is "
672
+ "always fatal)."
673
+ )
674
+ if tree_minus_chart and not prune_tree_to_chart:
675
+ parts.append(
676
+ f"{len(tree_minus_chart)} tree tip(s) are not present in the "
677
+ "chart. Pass `prune_tree_to_chart=True` to drop them automatically."
678
+ )
679
+ parts.append("")
680
+ parts.append(
681
+ f"Tried: chart_strain_field={chart_strain_field!r}, "
682
+ f"tree_strain_field={tree_strain_field!r}"
683
+ )
684
+ parts.append("Sample chart_strain_field values: " f"{sorted(chart_strains)[:5]}")
685
+ parts.append("Sample tree_strain_field values: " f"{sorted(tree_strains)[:5]}")
686
+ if chart_minus_tree:
687
+ parts.append(f"Sample chart-only values: {sorted(chart_minus_tree)[:5]}")
688
+ if tree_minus_chart and not prune_tree_to_chart:
689
+ parts.append(f"Sample tree-only values: {sorted(tree_minus_chart)[:5]}")
690
+ if hints:
691
+ parts.append("")
692
+ parts.append("Possible alternatives:")
693
+ for h in hints:
694
+ parts.append(f" - {h}")
695
+ return "\n".join(parts)
696
+
697
+
698
+ def _candidate_field_hints(
699
+ *,
700
+ chart_spec: dict,
701
+ chart_strain_field: str,
702
+ tree_strains: list[str],
703
+ tree_source: Any,
704
+ tree_strain_field: str,
705
+ chart_strains: list[str],
706
+ ) -> list[str]:
707
+ """Return human-readable hints suggesting alternative strain fields.
708
+
709
+ Scans:
710
+ - chart side: every column appearing in inline / named data of the
711
+ spec. For each, fraction of values that are in tree_strains.
712
+ - tree side: every node_attrs key on tips, plus the literal "name"
713
+ (top-level Auspice tip ID). For each, fraction of values that are
714
+ in chart_strains. Skipped if `tree_source` is a parsed TreeNode
715
+ rather than a path/dict (we'd have no original Auspice JSON to
716
+ introspect).
717
+
718
+ Threshold: 50% overlap. Tunable; lower threshold trades fewer
719
+ false-negatives for more false-positives in the hint.
720
+ """
721
+ OVERLAP_THRESHOLD = 0.5
722
+ hints: list[str] = []
723
+ tree_set = set(tree_strains)
724
+ chart_set = set(chart_strains)
725
+
726
+ if tree_set:
727
+ for field_name, values in _enumerate_chart_data_columns(chart_spec):
728
+ if field_name == chart_strain_field:
729
+ continue
730
+ distinct = set(values)
731
+ if not distinct:
732
+ continue
733
+ overlap = len(distinct & tree_set)
734
+ if overlap / len(tree_set) >= OVERLAP_THRESHOLD:
735
+ hints.append(
736
+ f"the chart has a field {field_name!r} whose values "
737
+ f"match {overlap}/{len(tree_set)} tree strains — did "
738
+ f"you mean chart_strain_field={field_name!r}?"
739
+ )
740
+
741
+ tree_dict = _tree_source_as_dict(tree_source)
742
+ if chart_set and tree_dict is not None and "tree" in tree_dict:
743
+ for attr, values in _enumerate_tree_tip_attrs(tree_dict["tree"]):
744
+ if attr == tree_strain_field:
745
+ continue
746
+ distinct = set(values)
747
+ if not distinct:
748
+ continue
749
+ overlap = len(distinct & chart_set)
750
+ if overlap / len(chart_set) >= OVERLAP_THRESHOLD:
751
+ hint_lhs = (
752
+ "the tree has node `name` field"
753
+ if attr == "name"
754
+ else f"the tree has node_attrs[{attr!r}]"
755
+ )
756
+ hints.append(
757
+ f"{hint_lhs} whose values match {overlap}/"
758
+ f"{len(chart_set)} chart strains — did you mean "
759
+ f"tree_strain_field={attr!r}?"
760
+ )
761
+
762
+ return hints
763
+
764
+
765
+ def _enumerate_chart_data_columns(spec: Any) -> list[tuple[str, list]]:
766
+ """Walk the spec for every column appearing in inline / named data.
767
+
768
+ Returns `[(field_name, values), ...]` where `values` is the list of
769
+ raw values seen for that field across all data rows visited (with
770
+ duplicates).
771
+ """
772
+ datasets = spec.get("datasets", {}) if isinstance(spec, dict) else {}
773
+ by_field: dict[str, list] = {}
774
+
775
+ def walk(node: Any) -> None:
776
+ if isinstance(node, dict):
777
+ data = node.get("data")
778
+ if isinstance(data, dict):
779
+ rows: list | None = None
780
+ if "values" in data and isinstance(data["values"], list):
781
+ rows = data["values"]
782
+ elif "name" in data:
783
+ name = data["name"]
784
+ if isinstance(datasets.get(name), list):
785
+ rows = datasets[name]
786
+ if rows:
787
+ for row in rows:
788
+ if isinstance(row, dict):
789
+ for k, v in row.items():
790
+ by_field.setdefault(k, []).append(v)
791
+ for k, v in node.items():
792
+ if k in ("data", "datasets"):
793
+ continue
794
+ walk(v)
795
+ elif isinstance(node, list):
796
+ for item in node:
797
+ walk(item)
798
+
799
+ walk(spec)
800
+ return list(by_field.items())
801
+
802
+
803
+ def _tree_source_as_dict(tree_source: Any) -> dict | None:
804
+ """Return the original Auspice JSON dict if available, else None.
805
+
806
+ We use this for the candidate-field hint on the tree side. If the user
807
+ passed a pre-parsed `TreeNode`, we can't introspect the original
808
+ `node_attrs` keys and skip the tree-side hint.
809
+ """
810
+ if isinstance(tree_source, dict):
811
+ return tree_source
812
+ if isinstance(tree_source, (str, Path)):
813
+ try:
814
+ with open(tree_source) as f:
815
+ return json.load(f)
816
+ except (OSError, json.JSONDecodeError):
817
+ return None
818
+ return None
819
+
820
+
821
+ def _enumerate_tree_tip_attrs(
822
+ tree_root_dict: dict,
823
+ ) -> list[tuple[str, list]]:
824
+ """Walk an Auspice JSON tree dict and yield (attr, values) per tip-attr.
825
+
826
+ Includes a synthetic `"name"` entry for the top-level node `name` field
827
+ (the canonical Auspice tip identifier, used by `tree_strain_field="name"`).
828
+ Auspice's `{value: ...}` convention is auto-unwrapped.
829
+ """
830
+ by_attr: dict[str, list] = {}
831
+ names: list = []
832
+
833
+ def walk(n: dict) -> None:
834
+ children = n.get("children")
835
+ if children:
836
+ for c in children:
837
+ walk(c)
838
+ return
839
+ nm = n.get("name")
840
+ if nm is not None:
841
+ names.append(nm)
842
+ for k, v in n.get("node_attrs", {}).items():
843
+ if isinstance(v, dict) and "value" in v:
844
+ v = v["value"]
845
+ by_attr.setdefault(k, []).append(v)
846
+
847
+ walk(tree_root_dict)
848
+
849
+ out: list[tuple[str, list]] = []
850
+ if names:
851
+ out.append(("name", names))
852
+ out.extend(by_attr.items())
853
+ return out
854
+
855
+
856
+ def _apply_tree_order_to_chart_object(
857
+ chart: alt.TopLevelMixin, chart_strain_field: str, sort_order: list[str]
858
+ ) -> alt.TopLevelMixin:
859
+ """Return a deepcopy of the user's chart with `sort=sort_order` applied
860
+ to every axis encoding that references `chart_strain_field`.
861
+
862
+ Walks the live altair object tree (Chart / LayerChart / FacetChart /
863
+ HConcatChart / VConcatChart / ConcatChart) rather than its dict form,
864
+ because altair's to_dict / from_dict round-trip introduces internal
865
+ annotations (e.g. params[].views) and class-dispatch issues that the
866
+ dict approach has to fight. Modifying the object in place is robust as
867
+ long as altair's container attribute names hold (.hconcat / .vconcat /
868
+ .concat / .layer / .spec / .encoding), which is stable in altair 5+.
869
+ """
870
+ new_chart = copy.deepcopy(chart)
871
+ _walk_and_apply_sort(new_chart, chart_strain_field, sort_order)
872
+ return new_chart
873
+
874
+
875
+ def _walk_and_apply_sort(
876
+ node: Any, chart_strain_field: str, sort_order: list[str]
877
+ ) -> None:
878
+ """Recursively set sort on every encoding whose field == chart_strain_field
879
+ on a live altair chart object."""
880
+ enc = _live_attr(node, "encoding")
881
+ if enc is not None:
882
+ for channel in ("x", "y"):
883
+ ch = _live_attr(enc, channel)
884
+ if ch is not None and _channel_field(ch) == chart_strain_field:
885
+ ch.sort = list(sort_order)
886
+ for attr in ("hconcat", "vconcat", "concat", "layer"):
887
+ sub = _live_attr(node, attr)
888
+ if isinstance(sub, list):
889
+ for s in sub:
890
+ _walk_and_apply_sort(s, chart_strain_field, sort_order)
891
+ # FacetChart.spec is the chart being faceted; recurse unconditionally so
892
+ # we descend into the inner LayerChart / Chart that actually carries the
893
+ # encoding. Gating on `spec.encoding is not None` was wrong because a
894
+ # LayerChart has no top-level encoding — its encodings live on its layers.
895
+ spec = _live_attr(node, "spec")
896
+ if spec is not None:
897
+ _walk_and_apply_sort(spec, chart_strain_field, sort_order)
898
+
899
+
900
+ def _live_attr(obj: Any, name: str) -> Any:
901
+ """Return obj.name unless it's missing or altair's Undefined sentinel."""
902
+ v = getattr(obj, name, None)
903
+ if v is None:
904
+ return None
905
+ if v is alt.Undefined:
906
+ return None
907
+ return v
908
+
909
+
910
+ def _pop_toplevel_only_attrs(
911
+ chart: alt.TopLevelMixin,
912
+ ) -> tuple[Any, dict]:
913
+ """Pop top-level-only attrs off chart._kwds so altair lets us nest it.
914
+
915
+ Returns (config, other) where `config` is the popped Config (or Undefined)
916
+ and `other` is a dict of any other popped attrs ($schema, padding,
917
+ autosize, background, datasets, usermeta) that are non-Undefined.
918
+
919
+ Vega-Lite forbids these properties on subspecs; altair enforces this in
920
+ `_check_if_valid_subspec` (or in `to_dict` validation for some keys).
921
+ We move them onto the outer HConcatChart we build. `datasets` in
922
+ particular shows up here only when the user passed a JSON / HTML / dict
923
+ chart — `alt.Chart.from_dict(...)` parks the data dict at the top
924
+ level, but the outer hconcat we wrap it in won't serialize cleanly
925
+ with `datasets` on a subspec.
926
+ """
927
+ config = chart._kwds.pop("config", alt.Undefined)
928
+ other: dict = {}
929
+ for k in ("$schema", "padding", "autosize", "background", "datasets", "usermeta"):
930
+ v = chart._kwds.pop(k, alt.Undefined)
931
+ if v is not alt.Undefined:
932
+ other[k] = v
933
+ return config, other
934
+
935
+
936
+ def _apply_combined_config(combined: alt.HConcatChart, hoisted_config: Any) -> None:
937
+ """Apply the user chart's hoisted top-level config to the combined chart.
938
+
939
+ The user's config drives the chart panel's appearance unchanged
940
+ (including `view.stroke` if the user set one via `.configure_view(...)`);
941
+ the tree panel handles its own border at the panel level (see
942
+ `_build_tree_chart`'s `view = ViewBackground(stroke=None)`). If the user
943
+ had no config, we don't add one — the chart panel gets Vega-Lite's
944
+ defaults and the tree's panel-level view still wins.
945
+ """
946
+ if hoisted_config is alt.Undefined:
947
+ return
948
+ if hasattr(hoisted_config, "to_dict"):
949
+ combined._kwds["config"] = alt.Config.from_dict(hoisted_config.to_dict())
950
+ else:
951
+ combined._kwds["config"] = alt.Config.from_dict(dict(hoisted_config))
952
+
953
+
954
+ def _channel_field(ch: Any) -> str | None:
955
+ """Read the underlying `field` string from an altair channel object.
956
+
957
+ `ch.field` returns altair's `_PropertySetter` (used for fluent chaining),
958
+ not the stored field name. The stored value is reachable via `to_dict()`.
959
+ """
960
+ to_dict = getattr(ch, "to_dict", None)
961
+ if not callable(to_dict):
962
+ return None
963
+ try:
964
+ d = to_dict()
965
+ except Exception:
966
+ return None
967
+ if isinstance(d, dict):
968
+ f = d.get("field")
969
+ if isinstance(f, str):
970
+ return f
971
+ return None
972
+
973
+
974
+ def _chart_strain_dim(
975
+ spec: dict, axis_hits: list[_AxisHit], axis: str
976
+ ) -> int | float | dict:
977
+ """Return the dim value (height for axis=y, width for axis=x) on the
978
+ panel containing the first axis hit.
979
+
980
+ Walks the prefix of the hit's path from longest to shortest, returning
981
+ the value of the dim key on the nearest enclosing dict that has it.
982
+ Raises if no enclosing dict has it.
983
+ """
984
+ dim_key = "height" if axis == "y" else "width"
985
+ path = axis_hits[0][0]
986
+ for prefix_len in range(len(path), -1, -1):
987
+ node = _get_at_path(spec, path[:prefix_len])
988
+ if isinstance(node, dict) and dim_key in node:
989
+ return node[dim_key]
990
+ raise ValueError(
991
+ "the chart panel containing chart_strain_field has no explicit "
992
+ f"{dim_key!r} property; set the chart's {dim_key} explicitly so the "
993
+ f"tree panel can be sized to match (e.g. .properties({dim_key}="
994
+ f"alt.Step(11)) for one row per strain, or .properties({dim_key}=300) "
995
+ "for fixed pixels)."
996
+ )
997
+
998
+
999
+ def _get_at_path(spec: Any, path: tuple) -> Any:
1000
+ cur = spec
1001
+ for p in path:
1002
+ cur = cur[p]
1003
+ return cur
1004
+
1005
+
1006
+ def _coerce_dim(v: int | float | dict, *, n_tips: int) -> int | float:
1007
+ """Turn the chart's strain-axis dim into a pixel height for the tree.
1008
+
1009
+ `Step(N)` on the chart means the chart's strain-axis body is `N * n_tips`
1010
+ pixels wide/tall (a Vega-Lite point/band scale with the default
1011
+ `paddingOuter=0.5` puts the first/last items half a step inside the
1012
+ panel). We can't pass `Step(N)` straight through to the tree because the
1013
+ tree's tip-axis is *quantitative* — Vega-Lite ignores `Step` on
1014
+ quantitative axes and falls back to a default height. So we convert to
1015
+ fixed pixels here. The tree's y-domain is set to `[-0.5, n_tips - 0.5]`,
1016
+ which positions tip i exactly where row i sits on the chart's point/band
1017
+ scale.
1018
+
1019
+ For an int/float dim the chart used a fixed pixel height; we propagate
1020
+ it directly.
1021
+ """
1022
+ if isinstance(v, dict) and "step" in v:
1023
+ return v["step"] * n_tips
1024
+ if isinstance(v, (int, float)):
1025
+ return v
1026
+ raise ValueError(
1027
+ f"unsupported chart-strain dim value {v!r}; expected an int/float "
1028
+ "(fixed pixels) or {'step': N} (alt.Step)."
1029
+ )
1030
+
1031
+
1032
+ # ---------- tree panel construction (unchanged from Phase 1) ----------
1033
+
1034
+
1035
+ _SCALE_BAR_EXTRA_PIXELS = 35
1036
+
1037
+
1038
+ def _nice_scale_bar_length(branch_range: float) -> float:
1039
+ """Return the largest 1/2/5 × 10^k value ≤ 25% of `branch_range`.
1040
+
1041
+ Examples (target = 0.25 * branch_range):
1042
+ target=0.04 → 0.02 (m=2, k=-2)
1043
+ target=0.07 → 0.05 (m=5, k=-2)
1044
+ target=1.0 → 1.0 (m=1, k=0)
1045
+ target=3.5 → 2.0 (m=2, k=0)
1046
+ """
1047
+ target = branch_range * 0.25
1048
+ if target <= 0:
1049
+ return 0.0
1050
+ k = math.floor(math.log10(target))
1051
+ base = 10.0**k
1052
+ for m in (5, 2, 1):
1053
+ if m * base <= target:
1054
+ return m * base
1055
+ return base # unreachable; m=1 always fits since base ≤ target
1056
+
1057
+
1058
+ def _format_scale_bar_label(
1059
+ length: float, branch_length: str, units: str | None
1060
+ ) -> str:
1061
+ """Format the scale-bar text per `branch_length`.
1062
+
1063
+ For `"div"` the user-supplied `units` (or no unit) is appended.
1064
+ For `"num_date"` the unit is automatic: `years` if `length >= 1`,
1065
+ otherwise `months` (rounded). `units` is ignored in the num_date case.
1066
+ """
1067
+ if branch_length == "num_date":
1068
+ if length >= 1:
1069
+ return f"{length:g} years"
1070
+ months = round(length * 12)
1071
+ return f"{months} months"
1072
+ if units:
1073
+ return f"{length:g} {units}"
1074
+ return f"{length:g}"
1075
+
1076
+
1077
+ def _build_scale_bar_layer(
1078
+ *,
1079
+ branch_min: float,
1080
+ branch_max: float,
1081
+ bar_length: float,
1082
+ n_tips: int,
1083
+ extra_units: float,
1084
+ strain_axis: str,
1085
+ label: str,
1086
+ ) -> alt.LayerChart:
1087
+ """Build a 2-layer (bar rule + text) chart for the scale bar.
1088
+
1089
+ The bar lives in the extra tip-axis strip past the last tip, parallel
1090
+ to the branch axis, and is **centered on the branch range** so the
1091
+ text label (which is typically wider than the bar) doesn't overflow
1092
+ the tree panel's edge.
1093
+
1094
+ For vertical layouts (strain on y) the text is horizontal, placed
1095
+ just below the bar. For horizontal layouts (strain on x) the bar is
1096
+ vertical and the text is rotated 270° so it reads bottom-to-top,
1097
+ parallel to the bar.
1098
+
1099
+ The bar/text data uses the same column names as `_tree.segments`
1100
+ ("x" for branch values, "y" for tip values) so it shares scales with
1101
+ the rest of the tree's layers.
1102
+ """
1103
+ # Positions inside the extra tip-axis strip. Tightened from the
1104
+ # earlier 0.35 / 0.78 split: text now sits ~7 px below the bar in
1105
+ # vertical mode (or ~7 px past it in horizontal mode).
1106
+ bar_tip_pos = n_tips - 0.5 + extra_units * 0.20
1107
+ text_tip_pos = n_tips - 0.5 + extra_units * 0.45
1108
+
1109
+ # Center the bar on the branch range (was anchored at branch_min,
1110
+ # which left long text labels hanging past the panel edge).
1111
+ branch_mid = (branch_min + branch_max) / 2
1112
+ bar_b_start = branch_mid - bar_length / 2
1113
+ bar_b_end = branch_mid + bar_length / 2
1114
+
1115
+ bar_df = pd.DataFrame([{"x": bar_b_start, "x2": bar_b_end, "y": bar_tip_pos}])
1116
+ text_df = pd.DataFrame([{"x": branch_mid, "y": text_tip_pos, "label": label}])
1117
+
1118
+ if strain_axis == "y":
1119
+ # Bar horizontal across the branch axis; text horizontal below it.
1120
+ bar = (
1121
+ alt.Chart(bar_df)
1122
+ .mark_rule(strokeWidth=2.0, color="black")
1123
+ .encode(x="x:Q", x2="x2:Q", y="y:Q")
1124
+ )
1125
+ text = (
1126
+ alt.Chart(text_df)
1127
+ .mark_text(fontSize=10, align="center", baseline="top")
1128
+ .encode(x="x:Q", y="y:Q", text="label:N")
1129
+ )
1130
+ else:
1131
+ # Bar vertical along the branch axis; text rotated to read
1132
+ # parallel to the bar (270° = bottom-to-top), centered on its
1133
+ # anchor so it sits next to the bar without overlapping.
1134
+ bar = (
1135
+ alt.Chart(bar_df)
1136
+ .mark_rule(strokeWidth=2.0, color="black")
1137
+ .encode(y="x:Q", y2="x2:Q", x="y:Q")
1138
+ )
1139
+ text = (
1140
+ alt.Chart(text_df)
1141
+ .mark_text(fontSize=10, align="center", baseline="middle", angle=270)
1142
+ .encode(y="x:Q", x="y:Q", text="label:N")
1143
+ )
1144
+ return bar + text
1145
+
1146
+
1147
+ def _build_tree_chart(
1148
+ root: _tree.TreeNode,
1149
+ *,
1150
+ n_tips: int,
1151
+ tree_size: int,
1152
+ strain_dim: int | float | alt.Step,
1153
+ strain_axis: str,
1154
+ tree_location: TreeLocation,
1155
+ tree_line_width: float = 1.5,
1156
+ tree_node_size: float = 28,
1157
+ leader_line_width: float = 1.0,
1158
+ scale_bar: bool = False,
1159
+ branch_length: str = "div",
1160
+ branch_length_units: str | None = None,
1161
+ ) -> alt.Chart:
1162
+ """Build the tree panel.
1163
+
1164
+ `_tree.segments(root)` returns a dataframe with columns x, x2 (branch-axis
1165
+ values from the root's div) and y, y2 (tip-axis index). For each layout
1166
+ we bind those columns to chart x or chart y differently, and the
1167
+ branch_scale domain orientation depends on which side of the chart the
1168
+ tree sits on (so the tip-end of every branch always faces the chart):
1169
+
1170
+ - `tree_location="left"` (strain on y). Branch axis on chart x; root on
1171
+ the left, tips on the right (toward the chart). domain = [min, max].
1172
+ - `tree_location="right"` (strain on y). Branch axis on chart x; root on
1173
+ the right, tips on the left (toward the chart). domain = [max, min].
1174
+ - `tree_location="top"` (strain on x). Branch axis on chart y; root at
1175
+ the top, tips at the bottom (toward the chart). Vega-Lite y has
1176
+ domain[1] at the top, so root-on-top means domain = [max, min].
1177
+ - `tree_location="bottom"` (strain on x). Branch axis on chart y; root
1178
+ at the bottom, tips at the top (toward the chart). domain = [min, max].
1179
+
1180
+ Tip-axis scale and the leader_df are layout-direction-agnostic: tips at
1181
+ distance `tip.x < branch_max` get a leader from `tip.x` to `branch_max`
1182
+ in branch-coordinate, and the scale handles the pixel mapping.
1183
+ """
1184
+ if tree_line_width < 0:
1185
+ raise ValueError(f"tree_line_width must be >= 0, got {tree_line_width}")
1186
+ if tree_node_size < 0:
1187
+ raise ValueError(f"tree_node_size must be >= 0, got {tree_node_size}")
1188
+ if leader_line_width < 0:
1189
+ raise ValueError(f"leader_line_width must be >= 0, got {leader_line_width}")
1190
+
1191
+ seg_df = _tree.segments(root)
1192
+ tips_df = pd.DataFrame(
1193
+ [{"name": t.name, "x": t.x, "y": t.y} for t in _tree.tips(root)]
1194
+ )
1195
+ branch_max = float(seg_df[["x", "x2"]].max().max())
1196
+ branch_min = float(seg_df[["x", "x2"]].min().min())
1197
+ leader_df = tips_df[tips_df["x"] < branch_max].assign(x2=branch_max)
1198
+
1199
+ # When scale_bar is on, extend the tip-axis past the last tip by
1200
+ # `_SCALE_BAR_EXTRA_PIXELS` and matching extra data units. Per-row pixel
1201
+ # allocation (`step_px`) stays constant, so tips 0..n-1 keep the exact
1202
+ # same on-screen positions they had before — only the panel is taller
1203
+ # (vertical) or wider (horizontal). Tip-row alignment with the chart
1204
+ # is therefore preserved; the scale bar extends beyond the chart's
1205
+ # panel edge, which the default top/left-aligned hconcat/vconcat
1206
+ # absorbs without disturbing alignment.
1207
+ if scale_bar:
1208
+ if not isinstance(strain_dim, (int, float)):
1209
+ raise ValueError(
1210
+ "scale_bar requires a numeric strain dimension; got "
1211
+ f"{type(strain_dim).__name__}"
1212
+ )
1213
+ step_px = strain_dim / n_tips
1214
+ extra_units = _SCALE_BAR_EXTRA_PIXELS / step_px
1215
+ extended_strain_dim = strain_dim + _SCALE_BAR_EXTRA_PIXELS
1216
+ bar_length = _nice_scale_bar_length(branch_max - branch_min)
1217
+ bar_label = _format_scale_bar_label(
1218
+ bar_length, branch_length, branch_length_units
1219
+ )
1220
+ scale_bar_layer = _build_scale_bar_layer(
1221
+ branch_min=branch_min,
1222
+ branch_max=branch_max,
1223
+ bar_length=bar_length,
1224
+ n_tips=n_tips,
1225
+ extra_units=extra_units,
1226
+ strain_axis=strain_axis,
1227
+ label=bar_label,
1228
+ )
1229
+ else:
1230
+ extra_units = 0.0
1231
+ extended_strain_dim = strain_dim
1232
+ scale_bar_layer = None
1233
+
1234
+ if strain_axis == "y":
1235
+ # Vertical: branch axis on chart x; tip axis with tip 0 on top.
1236
+ # tree_location flips the branch domain so tips face the chart side.
1237
+ branch_domain = (
1238
+ [branch_min, branch_max]
1239
+ if tree_location == "left"
1240
+ else [branch_max, branch_min]
1241
+ )
1242
+ branch_scale = alt.Scale(domain=branch_domain, nice=False, zero=False)
1243
+ # tip-axis domain[0] (at panel bottom) extends past last tip when
1244
+ # scale_bar=True; tip i still sits at the same on-screen pixel.
1245
+ tip_scale = alt.Scale(
1246
+ domain=[n_tips - 0.5 + extra_units, -0.5], nice=False, zero=False
1247
+ )
1248
+ branch_enc = alt.X("x:Q", axis=None, scale=branch_scale)
1249
+ tip_enc = alt.Y("y:Q", axis=None, scale=tip_scale)
1250
+ layers: list[alt.Chart] = []
1251
+ if leader_line_width > 0:
1252
+ layers.append(
1253
+ alt.Chart(leader_df)
1254
+ .mark_rule(
1255
+ stroke="#888",
1256
+ strokeWidth=leader_line_width,
1257
+ strokeDash=[2, 2],
1258
+ )
1259
+ .encode(x=branch_enc, x2="x2:Q", y=tip_enc)
1260
+ )
1261
+ layers.append(
1262
+ alt.Chart(seg_df)
1263
+ .mark_rule(strokeWidth=tree_line_width)
1264
+ .encode(x=branch_enc, x2="x2:Q", y=tip_enc, y2="y2:Q")
1265
+ )
1266
+ if tree_node_size > 0:
1267
+ layers.append(
1268
+ alt.Chart(tips_df)
1269
+ .mark_circle(size=tree_node_size, color="black")
1270
+ .encode(
1271
+ x=branch_enc,
1272
+ y=tip_enc,
1273
+ tooltip=alt.Tooltip("name:N", title="strain"),
1274
+ )
1275
+ )
1276
+ if scale_bar_layer is not None:
1277
+ layers.append(scale_bar_layer)
1278
+ layered = alt.layer(*layers)
1279
+ layered = layered.properties(width=tree_size, height=extended_strain_dim)
1280
+ elif strain_axis == "x":
1281
+ # Horizontal: branch axis on chart y (Vega-Lite default has domain[1]
1282
+ # at the top); tip axis with tip 0 on the left.
1283
+ # tree_location="top" → root at top → branch_max at bottom.
1284
+ # tree_location="bottom" → root at bottom → branch_max at top.
1285
+ branch_domain = (
1286
+ [branch_max, branch_min]
1287
+ if tree_location == "top"
1288
+ else [branch_min, branch_max]
1289
+ )
1290
+ branch_scale = alt.Scale(domain=branch_domain, nice=False, zero=False)
1291
+ # tip-axis domain[1] (at panel right) extends past last tip when
1292
+ # scale_bar=True.
1293
+ tip_scale = alt.Scale(
1294
+ domain=[-0.5, n_tips - 0.5 + extra_units], nice=False, zero=False
1295
+ )
1296
+ branch_enc = alt.Y("x:Q", axis=None, scale=branch_scale)
1297
+ tip_enc = alt.X("y:Q", axis=None, scale=tip_scale)
1298
+ layers = []
1299
+ if leader_line_width > 0:
1300
+ layers.append(
1301
+ alt.Chart(leader_df)
1302
+ .mark_rule(
1303
+ stroke="#888",
1304
+ strokeWidth=leader_line_width,
1305
+ strokeDash=[2, 2],
1306
+ )
1307
+ .encode(y=branch_enc, y2="x2:Q", x=tip_enc)
1308
+ )
1309
+ layers.append(
1310
+ alt.Chart(seg_df)
1311
+ .mark_rule(strokeWidth=tree_line_width)
1312
+ .encode(y=branch_enc, y2="x2:Q", x=tip_enc, x2="y2:Q")
1313
+ )
1314
+ if tree_node_size > 0:
1315
+ layers.append(
1316
+ alt.Chart(tips_df)
1317
+ .mark_circle(size=tree_node_size, color="black")
1318
+ .encode(
1319
+ y=branch_enc,
1320
+ x=tip_enc,
1321
+ tooltip=alt.Tooltip("name:N", title="strain"),
1322
+ )
1323
+ )
1324
+ if scale_bar_layer is not None:
1325
+ layers.append(scale_bar_layer)
1326
+ layered = alt.layer(*layers)
1327
+ layered = layered.properties(width=extended_strain_dim, height=tree_size)
1328
+ else:
1329
+ raise ValueError(f"strain_axis must be 'x' or 'y', got {strain_axis!r}")
1330
+
1331
+ # Suppress the panel border on the tree itself. This is a panel-level
1332
+ # Vega-Lite `view` property, which overrides any inherited
1333
+ # `config.view.stroke` (e.g. if the user's chart was built with
1334
+ # `.configure_view(stroke="black")`, that stroke applies to the chart
1335
+ # panel but not to the tree).
1336
+ layered._kwds["view"] = alt.ViewBackground(stroke=None)
1337
+ return layered