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.
- tree_annotated_plot/__init__.py +11 -0
- tree_annotated_plot/_config.py +215 -0
- tree_annotated_plot/_plot.py +1337 -0
- tree_annotated_plot/_tree.py +307 -0
- tree_annotated_plot/cli.py +165 -0
- tree_annotated_plot-0.0.1.dist-info/METADATA +225 -0
- tree_annotated_plot-0.0.1.dist-info/RECORD +10 -0
- tree_annotated_plot-0.0.1.dist-info/WHEEL +4 -0
- tree_annotated_plot-0.0.1.dist-info/entry_points.txt +2 -0
- tree_annotated_plot-0.0.1.dist-info/licenses/LICENSE +19 -0
|
@@ -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
|