tree-annotated-plot 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tree_annotated_plot/_config.py +53 -6
- tree_annotated_plot/_label_style.py +349 -0
- tree_annotated_plot/_plot.py +117 -77
- tree_annotated_plot/cli.py +62 -12
- {tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/METADATA +3 -3
- tree_annotated_plot-0.5.0.dist-info/RECORD +12 -0
- {tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/WHEEL +1 -1
- tree_annotated_plot-0.3.0.dist-info/RECORD +0 -11
- {tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/entry_points.txt +0 -0
- {tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/licenses/LICENSE +0 -0
tree_annotated_plot/_config.py
CHANGED
|
@@ -68,6 +68,15 @@ class PlotConfig:
|
|
|
68
68
|
"(x-encoded).",
|
|
69
69
|
] = None
|
|
70
70
|
|
|
71
|
+
spacing: Annotated[
|
|
72
|
+
int,
|
|
73
|
+
"Pixels of blank space between the tree panel and the chart panel. "
|
|
74
|
+
"Default 0, which butts the two together. Distinct from "
|
|
75
|
+
"`shift_tree_loc`, which moves the tree within its own panel by "
|
|
76
|
+
"resizing the label strip; `spacing` sets the gap between the panels "
|
|
77
|
+
"and applies whether or not `connect_leader_to_label` is on.",
|
|
78
|
+
] = 0
|
|
79
|
+
|
|
71
80
|
tree_line_width: Annotated[
|
|
72
81
|
float,
|
|
73
82
|
"Stroke width (px) for the tree's branch lines. Default 2.",
|
|
@@ -152,11 +161,34 @@ class PlotConfig:
|
|
|
152
161
|
"when `connect_leader_to_label` is on.",
|
|
153
162
|
] = "normal"
|
|
154
163
|
|
|
164
|
+
strain_label_styles: Annotated[
|
|
165
|
+
list[dict[str, Any]] | None,
|
|
166
|
+
"Style a subset of the strain labels rendered in the tree panel; "
|
|
167
|
+
"requires `connect_leader_to_label`. A list of rules, each a dict "
|
|
168
|
+
'with keys "strains" (a list of tip identifiers, i.e. '
|
|
169
|
+
'`tree_strain_field` values), "style" and, optionally, "when". '
|
|
170
|
+
'"style" holds Vega-Lite text-mark properties: "fill" (a color), '
|
|
171
|
+
'"opacity" (0 to 1), "fontWeight" ("normal", "bold", or 100-900) '
|
|
172
|
+
'and "fontStyle" ("normal" or "italic"). "when" is a Vega '
|
|
173
|
+
"expression that turns the rule on while it is truthy, usually just "
|
|
174
|
+
"the name of a parameter declared in the chart (e.g. one bound to a "
|
|
175
|
+
"checkbox), but any expression works (e.g. \"emphasis == 'cell'\" "
|
|
176
|
+
'for a radio button). Without "when" the style always applies. '
|
|
177
|
+
"Where rules overlap, later rules win property by property; "
|
|
178
|
+
"properties no rule sets keep `strain_label_font_weight` and the "
|
|
179
|
+
"default black, upright text. A strain that isn't a tree tip is an "
|
|
180
|
+
"error; strains dropped by `prune_tree_to_chart` are skipped. CLI "
|
|
181
|
+
"form: a JSON array (quote the whole argument), e.g. "
|
|
182
|
+
'\'[{"strains": ["K"], "style": {"fill": "#d62728", '
|
|
183
|
+
'"fontWeight": "bold"}, "when": "vaccine_strains"}]\'.',
|
|
184
|
+
] = None
|
|
185
|
+
|
|
155
186
|
shift_tree_loc: Annotated[
|
|
156
187
|
int,
|
|
157
188
|
"Pixels by which to shift the tree toward (positive) or away from "
|
|
158
|
-
"(negative) the chart
|
|
159
|
-
"connect_leader_to_label is off
|
|
189
|
+
"(negative) the chart, by resizing the label strip inside the tree "
|
|
190
|
+
"panel. Default 0. Has no effect when connect_leader_to_label is off; "
|
|
191
|
+
"to set the gap between the tree and chart panels use `spacing`.",
|
|
160
192
|
] = 0
|
|
161
193
|
|
|
162
194
|
color_tree_by: Annotated[
|
|
@@ -215,10 +247,25 @@ class PlotConfig:
|
|
|
215
247
|
|
|
216
248
|
|
|
217
249
|
# Sidecar for Python-docstring-only prose, keyed by PlotConfig field name.
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
PARAM_DOC_EXTRAS: dict[str, str] = {
|
|
250
|
+
# Add an entry when a field's docstring entry needs more than the CLI
|
|
251
|
+
# `--help` text covers. The extras are appended to the field's description in
|
|
252
|
+
# `tree_annotated_plot.plot.__doc__`, never in CLI --help.
|
|
253
|
+
PARAM_DOC_EXTRAS: dict[str, str] = {
|
|
254
|
+
"strain_label_styles": (
|
|
255
|
+
'In Python, "when" may also be an `altair.Parameter`, e.g. '
|
|
256
|
+
'`alt.param(name="vaccine_strains", value=True, '
|
|
257
|
+
"bind=alt.binding_checkbox())`, which is declared on the returned "
|
|
258
|
+
"chart if your chart doesn't already declare it; or an altair "
|
|
259
|
+
'parameter expression such as `emphasis == "cell"`. Only a bare '
|
|
260
|
+
"`altair.Parameter` is declared for you: parameters that an "
|
|
261
|
+
"expression refers to must be declared on the chart, and an "
|
|
262
|
+
"expression other than a bare name isn't checked until the chart "
|
|
263
|
+
"renders. A bare name or parameter must be a variable parameter: "
|
|
264
|
+
"selection parameters are rejected, since the label data doesn't "
|
|
265
|
+
"carry the fields a selection tests. Any iterable of strings (e.g. "
|
|
266
|
+
'a pandas Series) works for "strains".'
|
|
267
|
+
),
|
|
268
|
+
}
|
|
222
269
|
|
|
223
270
|
|
|
224
271
|
# Descriptions for the three data-input parameters. These can't live on
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Per-strain styling of the tree-panel strain labels (`strain_label_styles`).
|
|
2
|
+
|
|
3
|
+
Public entry points: :func:`resolve_rules` validates and normalizes the
|
|
4
|
+
user's rules, and :func:`mark_props` turns them into label mark properties.
|
|
5
|
+
|
|
6
|
+
Each rule names some strains, a style (a subset of Vega-Lite text-mark
|
|
7
|
+
properties) and, optionally, a `when` condition. Every property a rule sets
|
|
8
|
+
becomes one `alt.ExprRef` on the existing label mark(s), evaluated per label:
|
|
9
|
+
Vega-Lite compiles a mark-level ``{"expr": ...}`` into the Vega encode block,
|
|
10
|
+
where ``datum`` is the label's row. That is why a property with no encoding
|
|
11
|
+
channel (``fontWeight``, ``fontStyle``) can still vary by strain without
|
|
12
|
+
duplicating the label layers. Which rules a label belongs to is carried in
|
|
13
|
+
one boolean column per rule (:func:`membership_column`) on the label data.
|
|
14
|
+
|
|
15
|
+
``fontSize`` is deliberately not a style key: the label strip's width is
|
|
16
|
+
estimated from `strain_label_font_size`, so a per-strain size would need a
|
|
17
|
+
different layout calculation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterable, Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import altair as alt
|
|
30
|
+
from altair.expr.core import Expression
|
|
31
|
+
|
|
32
|
+
# Allowed `style` keys, in the order their expressions are emitted.
|
|
33
|
+
STYLE_KEYS = ("fill", "opacity", "fontWeight", "fontStyle")
|
|
34
|
+
|
|
35
|
+
# Style keys that change the glyph outline. The white halo layer drawn under
|
|
36
|
+
# each label has to follow these so it keeps masking the leader exactly where
|
|
37
|
+
# the text is; `fill` and `opacity` apply to the visible text only.
|
|
38
|
+
_GLYPH_KEYS = frozenset({"fontWeight", "fontStyle"})
|
|
39
|
+
|
|
40
|
+
_RULE_KEYS = frozenset({"strains", "style", "when"})
|
|
41
|
+
|
|
42
|
+
_FONT_WEIGHTS = ("normal", "bold")
|
|
43
|
+
_NUMERIC_FONT_WEIGHTS = tuple(range(100, 1000, 100))
|
|
44
|
+
_FONT_STYLES = ("normal", "italic")
|
|
45
|
+
|
|
46
|
+
# Hints for style keys a user is likely to reach for that we don't accept.
|
|
47
|
+
_STYLE_KEY_HINTS = {
|
|
48
|
+
"color": 'use "fill" to set the label color',
|
|
49
|
+
"fontSize": (
|
|
50
|
+
"per-strain font sizes are not supported because they change the "
|
|
51
|
+
"label-strip layout; use `strain_label_font_size` for all labels"
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# A bare Vega expression identifier, i.e. what a param name looks like.
|
|
56
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class LabelStyleRule:
|
|
61
|
+
"""One validated `strain_label_styles` rule.
|
|
62
|
+
|
|
63
|
+
`strains` holds only the strains drawn in the tree (pruned ones are
|
|
64
|
+
dropped); `when` is a Vega expression string, or None for always-on.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
strains: frozenset[str]
|
|
68
|
+
style: dict[str, Any]
|
|
69
|
+
when: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def membership_column(index: int) -> str:
|
|
73
|
+
"""Name of the boolean label-data column marking rule `index`'s strains."""
|
|
74
|
+
return f"_tap_label_style_{index}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_rules(
|
|
78
|
+
raw: Any,
|
|
79
|
+
*,
|
|
80
|
+
input_tip_names: Iterable[str],
|
|
81
|
+
drawn_tip_names: Iterable[str],
|
|
82
|
+
chart_spec: dict,
|
|
83
|
+
tree_strain_field: str,
|
|
84
|
+
) -> tuple[list[LabelStyleRule], list[alt.Parameter]]:
|
|
85
|
+
"""Validate `strain_label_styles` and return ``(rules, params_to_declare)``.
|
|
86
|
+
|
|
87
|
+
`input_tip_names` are the tree's tips before `prune_tree_to_chart`; a
|
|
88
|
+
strain outside them is an error. `drawn_tip_names` are the tips left
|
|
89
|
+
after pruning; strains pruned away are silently dropped from their rule.
|
|
90
|
+
`params_to_declare` lists the `alt.Parameter` objects passed as `when`
|
|
91
|
+
that the chart doesn't already declare, deduplicated by name.
|
|
92
|
+
"""
|
|
93
|
+
if raw is None:
|
|
94
|
+
return [], []
|
|
95
|
+
if isinstance(raw, (str, bytes, Mapping)) or not isinstance(raw, Iterable):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"strain_label_styles must be a list of rule dicts, each with "
|
|
98
|
+
f'"strains" and "style" keys; got {type(raw).__name__}.'
|
|
99
|
+
)
|
|
100
|
+
raw_rules = list(raw)
|
|
101
|
+
if not raw_rules:
|
|
102
|
+
return [], []
|
|
103
|
+
|
|
104
|
+
input_tips = set(input_tip_names)
|
|
105
|
+
drawn_tips = set(drawn_tip_names)
|
|
106
|
+
declared = _declared_params(chart_spec)
|
|
107
|
+
rule_params: dict[str, alt.Parameter] = {}
|
|
108
|
+
for rule in raw_rules:
|
|
109
|
+
when = rule.get("when") if isinstance(rule, Mapping) else None
|
|
110
|
+
if isinstance(when, alt.Parameter):
|
|
111
|
+
rule_params.setdefault(_param_name(when), when)
|
|
112
|
+
|
|
113
|
+
rules: list[LabelStyleRule] = []
|
|
114
|
+
for i, rule in enumerate(raw_rules):
|
|
115
|
+
if not isinstance(rule, Mapping):
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"strain_label_styles[{i}] must be a dict with "
|
|
118
|
+
f'"strains" and "style" keys; got {type(rule).__name__}.'
|
|
119
|
+
)
|
|
120
|
+
unknown_keys = set(rule) - _RULE_KEYS
|
|
121
|
+
if unknown_keys:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"strain_label_styles[{i}] has unknown key(s) "
|
|
124
|
+
f"{sorted(unknown_keys)}; allowed keys are "
|
|
125
|
+
f"{sorted(_RULE_KEYS)}."
|
|
126
|
+
)
|
|
127
|
+
for required in ("strains", "style"):
|
|
128
|
+
if required not in rule:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"strain_label_styles[{i}] is missing the required "
|
|
131
|
+
f"{required!r} key."
|
|
132
|
+
)
|
|
133
|
+
rules.append(
|
|
134
|
+
LabelStyleRule(
|
|
135
|
+
strains=_resolve_strains(
|
|
136
|
+
i,
|
|
137
|
+
rule["strains"],
|
|
138
|
+
input_tips=input_tips,
|
|
139
|
+
drawn_tips=drawn_tips,
|
|
140
|
+
tree_strain_field=tree_strain_field,
|
|
141
|
+
),
|
|
142
|
+
style=_validate_style(i, rule["style"]),
|
|
143
|
+
when=_resolve_when(
|
|
144
|
+
i,
|
|
145
|
+
rule.get("when"),
|
|
146
|
+
declared=declared,
|
|
147
|
+
rule_param_names=set(rule_params),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
to_declare = [p for name, p in rule_params.items() if name not in declared]
|
|
153
|
+
return rules, to_declare
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mark_props(
|
|
157
|
+
rules: list[LabelStyleRule], *, default_font_weight: str
|
|
158
|
+
) -> tuple[dict[str, alt.ExprRef], dict[str, alt.ExprRef]]:
|
|
159
|
+
"""Return ``(halo_props, text_props)``: mark properties for the label layers.
|
|
160
|
+
|
|
161
|
+
Only properties that some rule sets get an expression; the rest keep the
|
|
162
|
+
marks' static settings. Each expression is a chain of ternaries built in
|
|
163
|
+
rule order, so the last rule's test is evaluated first and later rules
|
|
164
|
+
win where strains overlap. The fallback is the label's unstyled value:
|
|
165
|
+
`default_font_weight` for ``fontWeight``, and Vega-Lite's text defaults
|
|
166
|
+
(black, fully opaque, upright) for the others.
|
|
167
|
+
"""
|
|
168
|
+
defaults = {
|
|
169
|
+
"fill": "black",
|
|
170
|
+
"opacity": 1,
|
|
171
|
+
"fontWeight": default_font_weight,
|
|
172
|
+
"fontStyle": "normal",
|
|
173
|
+
}
|
|
174
|
+
text_props: dict[str, alt.ExprRef] = {}
|
|
175
|
+
for key in STYLE_KEYS:
|
|
176
|
+
expr = json.dumps(defaults[key])
|
|
177
|
+
styled = False
|
|
178
|
+
for i, rule in enumerate(rules):
|
|
179
|
+
if key not in rule.style:
|
|
180
|
+
continue
|
|
181
|
+
test = f"datum[{json.dumps(membership_column(i))}]"
|
|
182
|
+
if rule.when is not None:
|
|
183
|
+
test = f"({rule.when}) && {test}"
|
|
184
|
+
expr = f"{test} ? {json.dumps(rule.style[key])} : ({expr})"
|
|
185
|
+
styled = True
|
|
186
|
+
if styled:
|
|
187
|
+
text_props[key] = alt.ExprRef(expr)
|
|
188
|
+
halo_props = {k: v for k, v in text_props.items() if k in _GLYPH_KEYS}
|
|
189
|
+
return halo_props, text_props
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_strains(
|
|
193
|
+
index: int,
|
|
194
|
+
strains: Any,
|
|
195
|
+
*,
|
|
196
|
+
input_tips: set[str],
|
|
197
|
+
drawn_tips: set[str],
|
|
198
|
+
tree_strain_field: str,
|
|
199
|
+
) -> frozenset[str]:
|
|
200
|
+
if isinstance(strains, (str, bytes, Mapping)) or not isinstance(strains, Iterable):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f'strain_label_styles[{index}]["strains"] must be a list of '
|
|
203
|
+
f"strain names; got {type(strains).__name__} {strains!r}."
|
|
204
|
+
)
|
|
205
|
+
strains = list(strains)
|
|
206
|
+
if not strains:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f'strain_label_styles[{index}]["strains"] is empty; a rule must '
|
|
209
|
+
"name at least one strain."
|
|
210
|
+
)
|
|
211
|
+
non_str = [s for s in strains if not isinstance(s, str)]
|
|
212
|
+
if non_str:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f'strain_label_styles[{index}]["strains"] must contain strings; '
|
|
215
|
+
f"got {non_str[:5]!r}."
|
|
216
|
+
)
|
|
217
|
+
unknown = sorted(set(strains) - input_tips)
|
|
218
|
+
if unknown:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f'strain_label_styles[{index}]["strains"] has {len(unknown)} '
|
|
221
|
+
"strain(s) that are not tips of the tree under "
|
|
222
|
+
f"tree_strain_field={tree_strain_field!r}.\n"
|
|
223
|
+
f"Sample unknown strains: {unknown[:5]}\n"
|
|
224
|
+
f"Sample tree tips: {sorted(input_tips)[:5]}"
|
|
225
|
+
)
|
|
226
|
+
return frozenset(s for s in strains if s in drawn_tips)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_style(index: int, style: Any) -> dict[str, Any]:
|
|
230
|
+
where = f'strain_label_styles[{index}]["style"]'
|
|
231
|
+
if not isinstance(style, Mapping) or not style:
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"{where} must be a non-empty dict of label properties (any of "
|
|
234
|
+
f"{list(STYLE_KEYS)}); got {style!r}."
|
|
235
|
+
)
|
|
236
|
+
for key, value in style.items():
|
|
237
|
+
if key not in STYLE_KEYS:
|
|
238
|
+
hint = _STYLE_KEY_HINTS.get(key)
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"{where} has unsupported key {key!r}; allowed keys are "
|
|
241
|
+
f"{list(STYLE_KEYS)}." + (f" Hint: {hint}." if hint else "")
|
|
242
|
+
)
|
|
243
|
+
if key == "fill":
|
|
244
|
+
ok = isinstance(value, str) and bool(value.strip())
|
|
245
|
+
expected = "a non-empty color string"
|
|
246
|
+
elif key == "opacity":
|
|
247
|
+
ok = (
|
|
248
|
+
isinstance(value, (int, float))
|
|
249
|
+
and not isinstance(value, bool)
|
|
250
|
+
and math.isfinite(value)
|
|
251
|
+
and 0 <= value <= 1
|
|
252
|
+
)
|
|
253
|
+
expected = "a number from 0 to 1"
|
|
254
|
+
elif key == "fontWeight":
|
|
255
|
+
ok = value in _FONT_WEIGHTS or (
|
|
256
|
+
isinstance(value, int)
|
|
257
|
+
and not isinstance(value, bool)
|
|
258
|
+
and value in _NUMERIC_FONT_WEIGHTS
|
|
259
|
+
)
|
|
260
|
+
expected = f"one of {list(_FONT_WEIGHTS)} or 100-900 in steps of 100"
|
|
261
|
+
else: # fontStyle
|
|
262
|
+
ok = value in _FONT_STYLES
|
|
263
|
+
expected = f"one of {list(_FONT_STYLES)}"
|
|
264
|
+
if not ok:
|
|
265
|
+
raise ValueError(f"{where}[{key!r}] must be {expected}; got {value!r}.")
|
|
266
|
+
return dict(style)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _resolve_when(
|
|
270
|
+
index: int,
|
|
271
|
+
when: Any,
|
|
272
|
+
*,
|
|
273
|
+
declared: dict[str, dict],
|
|
274
|
+
rule_param_names: set[str],
|
|
275
|
+
) -> str | None:
|
|
276
|
+
"""Return `when` as a Vega expression string (None means always on)."""
|
|
277
|
+
where = f'strain_label_styles[{index}]["when"]'
|
|
278
|
+
if when is None:
|
|
279
|
+
return None
|
|
280
|
+
if isinstance(when, alt.Parameter):
|
|
281
|
+
if when.param_type != "variable":
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"{where} is a selection parameter ({_param_name(when)!r}); "
|
|
284
|
+
"only variable parameters (alt.param(...), e.g. bound to a "
|
|
285
|
+
"checkbox) are supported, since the label data doesn't carry "
|
|
286
|
+
"the fields a selection tests."
|
|
287
|
+
)
|
|
288
|
+
return _param_name(when)
|
|
289
|
+
if isinstance(when, alt.ParameterExpression):
|
|
290
|
+
expr = when.to_dict().get("expr")
|
|
291
|
+
elif isinstance(when, Expression):
|
|
292
|
+
expr = when.to_dict()
|
|
293
|
+
elif isinstance(when, str):
|
|
294
|
+
expr = when
|
|
295
|
+
else:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
f"{where} must be a param name or Vega expression string, an "
|
|
298
|
+
"alt.Parameter, or an altair parameter expression; got "
|
|
299
|
+
f"{type(when).__name__}."
|
|
300
|
+
)
|
|
301
|
+
if not isinstance(expr, str) or not expr.strip():
|
|
302
|
+
raise ValueError(f"{where} must not be empty; got {when!r}.")
|
|
303
|
+
expr = expr.strip()
|
|
304
|
+
if _IDENTIFIER.fullmatch(expr) and expr not in rule_param_names:
|
|
305
|
+
if expr not in declared:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"{where}={expr!r} names no parameter declared in the chart. "
|
|
308
|
+
f"Declared parameters: {sorted(declared) or 'none'}. Declare "
|
|
309
|
+
"it on the chart (e.g. chart.add_params(alt.param(name="
|
|
310
|
+
f"{expr!r}, value=True, bind=alt.binding_checkbox()))) before "
|
|
311
|
+
"saving it, or, in Python, pass the alt.Parameter itself."
|
|
312
|
+
)
|
|
313
|
+
if "select" in declared[expr]:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
f"{where}={expr!r} is a selection parameter; only variable "
|
|
316
|
+
"parameters (e.g. bound to a checkbox) are supported, since "
|
|
317
|
+
"the label data doesn't carry the fields a selection tests."
|
|
318
|
+
)
|
|
319
|
+
return expr
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _declared_params(spec: Any) -> dict[str, dict]:
|
|
323
|
+
"""Map every param name declared anywhere in a chart spec to its definition."""
|
|
324
|
+
found: dict[str, dict] = {}
|
|
325
|
+
|
|
326
|
+
def walk(node: Any) -> None:
|
|
327
|
+
if isinstance(node, dict):
|
|
328
|
+
params = node.get("params")
|
|
329
|
+
if isinstance(params, list):
|
|
330
|
+
for p in params:
|
|
331
|
+
if isinstance(p, dict) and isinstance(p.get("name"), str):
|
|
332
|
+
found.setdefault(p["name"], p)
|
|
333
|
+
for k, v in node.items():
|
|
334
|
+
if k in ("data", "datasets"):
|
|
335
|
+
continue
|
|
336
|
+
walk(v)
|
|
337
|
+
elif isinstance(node, list):
|
|
338
|
+
for item in node:
|
|
339
|
+
walk(item)
|
|
340
|
+
|
|
341
|
+
walk(spec)
|
|
342
|
+
return found
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _param_name(param: alt.Parameter) -> str:
|
|
346
|
+
name = param.name
|
|
347
|
+
if hasattr(name, "to_dict"):
|
|
348
|
+
name = name.to_dict()
|
|
349
|
+
return str(name)
|
tree_annotated_plot/_plot.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Any, Literal
|
|
|
15
15
|
import altair as alt
|
|
16
16
|
import pandas as pd
|
|
17
17
|
|
|
18
|
-
from . import _color, _config, _tree
|
|
18
|
+
from . import _color, _config, _label_style, _tree
|
|
19
19
|
from ._config import PlotConfig, TreeLocation
|
|
20
20
|
|
|
21
21
|
# Vega-Lite `orient` values that anchor the legend to the chart's left or
|
|
@@ -49,6 +49,7 @@ def plot(
|
|
|
49
49
|
branch_length: Literal["div", "num_date"],
|
|
50
50
|
tree_size: int = 100,
|
|
51
51
|
tree_location: TreeLocation | None = None,
|
|
52
|
+
spacing: int = 0,
|
|
52
53
|
tree_line_width: float = 2.0,
|
|
53
54
|
tree_node_size: float = 45,
|
|
54
55
|
leader_line_width: float = 1.0,
|
|
@@ -60,6 +61,7 @@ def plot(
|
|
|
60
61
|
connect_leader_to_label: bool = False,
|
|
61
62
|
strain_label_font_size: float = 10.0,
|
|
62
63
|
strain_label_font_weight: Literal["normal", "bold"] = "normal",
|
|
64
|
+
strain_label_styles: list[dict[str, Any]] | None = None,
|
|
63
65
|
shift_tree_loc: int = 0,
|
|
64
66
|
color_tree_by: str | None = None,
|
|
65
67
|
tree_color_scale: dict[str, str] | None = None,
|
|
@@ -77,6 +79,7 @@ def plot(
|
|
|
77
79
|
branch_length=branch_length,
|
|
78
80
|
tree_size=tree_size,
|
|
79
81
|
tree_location=tree_location,
|
|
82
|
+
spacing=spacing,
|
|
80
83
|
tree_line_width=tree_line_width,
|
|
81
84
|
tree_node_size=tree_node_size,
|
|
82
85
|
leader_line_width=leader_line_width,
|
|
@@ -88,6 +91,7 @@ def plot(
|
|
|
88
91
|
connect_leader_to_label=connect_leader_to_label,
|
|
89
92
|
strain_label_font_size=strain_label_font_size,
|
|
90
93
|
strain_label_font_weight=strain_label_font_weight,
|
|
94
|
+
strain_label_styles=strain_label_styles,
|
|
91
95
|
shift_tree_loc=shift_tree_loc,
|
|
92
96
|
color_tree_by=color_tree_by,
|
|
93
97
|
tree_color_scale=tree_color_scale,
|
|
@@ -122,10 +126,12 @@ _PLOT_DOC_FOOTER = """
|
|
|
122
126
|
Returns
|
|
123
127
|
-------
|
|
124
128
|
altair.HConcatChart | altair.VConcatChart
|
|
125
|
-
For vertical layout (chart strain on `y`), an `HConcatChart`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
For vertical layout (chart strain on `y`), an `HConcatChart` whose
|
|
130
|
+
panels are the tree then the chart for `tree_location="left"` (the
|
|
131
|
+
default), or the chart then the tree for `"right"`. For horizontal
|
|
132
|
+
layout (chart strain on `x`), a `VConcatChart` whose panels are the
|
|
133
|
+
chart then the tree for `tree_location="bottom"` (the default), or the
|
|
134
|
+
tree then the chart for `"top"`.
|
|
129
135
|
"""
|
|
130
136
|
|
|
131
137
|
|
|
@@ -147,6 +153,14 @@ def _build(
|
|
|
147
153
|
set on a config object); `config` carries every styling / behavior
|
|
148
154
|
knob. Both surfaces converge here so they can never disagree.
|
|
149
155
|
"""
|
|
156
|
+
if config.strain_label_styles and not config.connect_leader_to_label:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
"strain_label_styles styles the strain labels rendered in the "
|
|
159
|
+
"tree panel, which exist only when connect_leader_to_label=True. "
|
|
160
|
+
"With it off, the strain labels are your chart's own axis labels; "
|
|
161
|
+
"style those with Vega-Lite's conditional axis-label properties "
|
|
162
|
+
"(e.g. labelColor / labelFontWeight expressions on datum.value)."
|
|
163
|
+
)
|
|
150
164
|
root, auspice_meta = _ensure_tree(
|
|
151
165
|
tree,
|
|
152
166
|
config.tree_strain_field,
|
|
@@ -155,6 +169,9 @@ def _build(
|
|
|
155
169
|
)
|
|
156
170
|
tip_list = _tree.layout(root)
|
|
157
171
|
tip_names = [t.name for t in tip_list]
|
|
172
|
+
# Kept for `strain_label_styles` validation: a styled strain must be a tip
|
|
173
|
+
# of the input tree even if `prune_tree_to_chart` later drops it.
|
|
174
|
+
input_tip_names = list(tip_names)
|
|
158
175
|
|
|
159
176
|
_check_no_duplicate_tip_strains(
|
|
160
177
|
tip_names, tree_strain_field=config.tree_strain_field
|
|
@@ -206,6 +223,14 @@ def _build(
|
|
|
206
223
|
tip_list = _tree.layout(root)
|
|
207
224
|
tip_names = [t.name for t in tip_list]
|
|
208
225
|
|
|
226
|
+
label_style_rules, label_style_params = _label_style.resolve_rules(
|
|
227
|
+
config.strain_label_styles,
|
|
228
|
+
input_tip_names=input_tip_names,
|
|
229
|
+
drawn_tip_names=tip_names,
|
|
230
|
+
chart_spec=spec,
|
|
231
|
+
tree_strain_field=config.tree_strain_field,
|
|
232
|
+
)
|
|
233
|
+
|
|
209
234
|
strain_dim = _coerce_dim(
|
|
210
235
|
_chart_strain_dim(spec, axis_hits, axis), n_tips=len(tip_names)
|
|
211
236
|
)
|
|
@@ -240,6 +265,7 @@ def _build(
|
|
|
240
265
|
connect_leader_to_label=config.connect_leader_to_label,
|
|
241
266
|
strain_label_font_size=config.strain_label_font_size,
|
|
242
267
|
strain_label_font_weight=config.strain_label_font_weight,
|
|
268
|
+
label_style_rules=label_style_rules,
|
|
243
269
|
shift_tree_loc=config.shift_tree_loc,
|
|
244
270
|
tip_names=tip_names,
|
|
245
271
|
color_mapping=color_mapping,
|
|
@@ -281,11 +307,23 @@ def _build(
|
|
|
281
307
|
hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
|
|
282
308
|
|
|
283
309
|
combined = _concat_for_location(
|
|
284
|
-
tree_chart=tree_chart,
|
|
310
|
+
tree_chart=tree_chart,
|
|
311
|
+
user_chart=new_chart,
|
|
312
|
+
location=location,
|
|
313
|
+
spacing=config.spacing,
|
|
285
314
|
)
|
|
286
315
|
_apply_combined_config(combined, hoisted_config)
|
|
287
316
|
for k, v in hoisted_other.items():
|
|
288
317
|
combined._kwds[k] = v
|
|
318
|
+
# Declare any `when` parameter the user's chart doesn't already declare.
|
|
319
|
+
# Appended to the top-level `params` directly: `combined.add_params(...)`
|
|
320
|
+
# pushes params down into each sub-chart instead, which raises on charts
|
|
321
|
+
# loaded from a spec (e.g. a `FacetSpec` has no `add_params`).
|
|
322
|
+
if label_style_params:
|
|
323
|
+
existing = _live_attr(combined, "params") or []
|
|
324
|
+
combined._kwds["params"] = list(existing) + [
|
|
325
|
+
p.param for p in label_style_params
|
|
326
|
+
]
|
|
289
327
|
return combined
|
|
290
328
|
|
|
291
329
|
|
|
@@ -320,6 +358,7 @@ def _concat_for_location(
|
|
|
320
358
|
tree_chart: alt.TopLevelMixin,
|
|
321
359
|
user_chart: alt.TopLevelMixin,
|
|
322
360
|
location: TreeLocation,
|
|
361
|
+
spacing: int,
|
|
323
362
|
) -> alt.HConcatChart | alt.VConcatChart:
|
|
324
363
|
"""Concat tree and chart in the order implied by the tree's location.
|
|
325
364
|
|
|
@@ -333,19 +372,19 @@ def _concat_for_location(
|
|
|
333
372
|
user-chart marks whose color values aren't in the tree's domain.
|
|
334
373
|
"""
|
|
335
374
|
if location == "left":
|
|
336
|
-
return alt.hconcat(tree_chart, user_chart, spacing=
|
|
375
|
+
return alt.hconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
|
|
337
376
|
y="independent", color="independent"
|
|
338
377
|
)
|
|
339
378
|
if location == "right":
|
|
340
|
-
return alt.hconcat(user_chart, tree_chart, spacing=
|
|
379
|
+
return alt.hconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
|
|
341
380
|
y="independent", color="independent"
|
|
342
381
|
)
|
|
343
382
|
if location == "top":
|
|
344
|
-
return alt.vconcat(tree_chart, user_chart, spacing=
|
|
383
|
+
return alt.vconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
|
|
345
384
|
x="independent", color="independent"
|
|
346
385
|
)
|
|
347
386
|
if location == "bottom":
|
|
348
|
-
return alt.vconcat(user_chart, tree_chart, spacing=
|
|
387
|
+
return alt.vconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
|
|
349
388
|
x="independent", color="independent"
|
|
350
389
|
)
|
|
351
390
|
raise ValueError(f"unreachable: tree_location={location!r}")
|
|
@@ -384,9 +423,15 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve
|
|
|
384
423
|
A live `alt.TopLevelMixin` is returned as-is — its `$schema` is
|
|
385
424
|
irrelevant since the constructing altair version is necessarily the
|
|
386
425
|
running altair version. For dict / JSON path / HTML path inputs we run
|
|
387
|
-
the Vega-Lite version check (under `strict_version`)
|
|
388
|
-
|
|
389
|
-
|
|
426
|
+
the Vega-Lite version check (under `strict_version`) and then dispatch to
|
|
427
|
+
the right chart subclass via `alt.Chart.from_dict(...)`.
|
|
428
|
+
|
|
429
|
+
The spec goes to `from_dict` unmodified. In particular, each selection
|
|
430
|
+
param's `views` must survive: when `_build` nests a compound chart (layer
|
|
431
|
+
/ facet / concat), altair keeps its params' existing `views`, and an
|
|
432
|
+
emptied list makes Vega-Lite define the selection in every view, which
|
|
433
|
+
fails with "Duplicate signal name". `views` is valid on top-level params,
|
|
434
|
+
which is the only place altair emits it.
|
|
390
435
|
"""
|
|
391
436
|
if isinstance(chart_input, alt.TopLevelMixin):
|
|
392
437
|
return chart_input
|
|
@@ -414,8 +459,9 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve
|
|
|
414
459
|
)
|
|
415
460
|
|
|
416
461
|
_check_chart_schema_version(spec, strict_version=strict_version)
|
|
462
|
+
# `from_dict` keeps references into the spec (e.g. the `datasets` row
|
|
463
|
+
# lists), so copy it to keep the caller's dict out of later mutation.
|
|
417
464
|
spec = copy.deepcopy(spec)
|
|
418
|
-
_strip_params_views(spec)
|
|
419
465
|
# `from_dict` with validate=True dispatches to the right subclass
|
|
420
466
|
# (Chart / LayerChart / FacetChart / HConcatChart / VConcatChart /
|
|
421
467
|
# ConcatChart) based on the spec's shape.
|
|
@@ -479,26 +525,6 @@ def _check_chart_schema_version(spec: dict, *, strict_version: bool) -> None:
|
|
|
479
525
|
)
|
|
480
526
|
|
|
481
527
|
|
|
482
|
-
def _strip_params_views(spec: Any) -> None:
|
|
483
|
-
"""Recursively delete `views` from every params[] entry.
|
|
484
|
-
|
|
485
|
-
altair's `to_dict()` annotates each selection-param with the views it's
|
|
486
|
-
bound to, but altair's own `from_dict()` validation rejects the field.
|
|
487
|
-
Stripping is safe: the renderer rebinds params to views on its own.
|
|
488
|
-
"""
|
|
489
|
-
if isinstance(spec, dict):
|
|
490
|
-
params = spec.get("params")
|
|
491
|
-
if isinstance(params, list):
|
|
492
|
-
for p in params:
|
|
493
|
-
if isinstance(p, dict):
|
|
494
|
-
p.pop("views", None)
|
|
495
|
-
for v in spec.values():
|
|
496
|
-
_strip_params_views(v)
|
|
497
|
-
elif isinstance(spec, list):
|
|
498
|
-
for item in spec:
|
|
499
|
-
_strip_params_views(item)
|
|
500
|
-
|
|
501
|
-
|
|
502
528
|
class _ScriptCollector(HTMLParser):
|
|
503
529
|
"""Collect the text content of every <script> tag in an HTML document."""
|
|
504
530
|
|
|
@@ -1475,6 +1501,11 @@ def _build_scale_bar_layer(
|
|
|
1475
1501
|
|
|
1476
1502
|
_LABEL_PAD_PX_MIN = 4
|
|
1477
1503
|
_LABEL_PAD_RATIO = 0.4 # `LABEL_PAD_PX = max(MIN, font_size * RATIO)`
|
|
1504
|
+
# Gap between the labels and the chart, kept separate from the tree-facing pad
|
|
1505
|
+
# above because it clears the chart's frame rather than the deepest tip, and so
|
|
1506
|
+
# wants a small fixed clearance rather than the pad's larger, faster-growing one.
|
|
1507
|
+
_LABEL_CHART_GAP_PX_MIN = 3
|
|
1508
|
+
_LABEL_CHART_GAP_RATIO = 0.2 # `LABEL_CHART_GAP_PX = max(MIN, font_size * RATIO)`
|
|
1478
1509
|
_LABEL_CHAR_PX_RATIO = 0.6 # rough proportional sans-serif glyph-width estimate
|
|
1479
1510
|
_LABEL_HALO_RATIO = 0.6 # white-halo strokeWidth as a fraction of font_size
|
|
1480
1511
|
|
|
@@ -1497,6 +1528,7 @@ def _build_tree_chart(
|
|
|
1497
1528
|
connect_leader_to_label: bool = False,
|
|
1498
1529
|
strain_label_font_size: float = 10.0,
|
|
1499
1530
|
strain_label_font_weight: str = "normal",
|
|
1531
|
+
label_style_rules: list[_label_style.LabelStyleRule] | None = None,
|
|
1500
1532
|
shift_tree_loc: int = 0,
|
|
1501
1533
|
tip_names: list[str] | None = None,
|
|
1502
1534
|
color_mapping: _color.ColorMapping | None = None,
|
|
@@ -1602,6 +1634,10 @@ def _build_tree_chart(
|
|
|
1602
1634
|
# text — and masks the dashed leader behind each label without any
|
|
1603
1635
|
# width estimation. Vega-Lite doesn't expose `paintOrder`, so the
|
|
1604
1636
|
# two-layer trick stands in for a single text-with-halo mark.
|
|
1637
|
+
# - `strain_label_styles` rules restyle individual labels by replacing
|
|
1638
|
+
# these layers' mark properties with per-label `datum` expressions
|
|
1639
|
+
# (see `_label_style`); the halo follows the glyph-shape ones
|
|
1640
|
+
# (fontWeight / fontStyle) so it keeps matching the text.
|
|
1605
1641
|
# - `shift_tree_loc` (pixels) shrinks the strip — bringing the tree
|
|
1606
1642
|
# visually closer to the labels — by reducing the data-units between
|
|
1607
1643
|
# branch_max and chart_edge_branch. The tree's tree_size-pixel
|
|
@@ -1609,14 +1645,37 @@ def _build_tree_chart(
|
|
|
1609
1645
|
# When connect_leader_to_label is off, the chart's natural strain-axis
|
|
1610
1646
|
# labels are kept and leaders stop at branch_max (the prior behavior).
|
|
1611
1647
|
halo_px = max(2.0, strain_label_font_size * _LABEL_HALO_RATIO)
|
|
1648
|
+
halo_style, text_style = _label_style.mark_props(
|
|
1649
|
+
label_style_rules or [], default_font_weight=strain_label_font_weight
|
|
1650
|
+
)
|
|
1651
|
+
label_font_kwargs = {
|
|
1652
|
+
"baseline": "middle",
|
|
1653
|
+
"fontSize": strain_label_font_size,
|
|
1654
|
+
"fontWeight": strain_label_font_weight,
|
|
1655
|
+
}
|
|
1656
|
+
label_halo_mark_kwargs = {
|
|
1657
|
+
**label_font_kwargs,
|
|
1658
|
+
"fill": "white",
|
|
1659
|
+
"stroke": "white",
|
|
1660
|
+
"strokeWidth": halo_px,
|
|
1661
|
+
"strokeJoin": "round",
|
|
1662
|
+
**halo_style,
|
|
1663
|
+
}
|
|
1664
|
+
label_text_mark_kwargs = {**label_font_kwargs, **text_style}
|
|
1612
1665
|
if connect_leader_to_label:
|
|
1613
1666
|
names = tip_names if tip_names is not None else list(tips_df["name"])
|
|
1614
1667
|
max_name_len = max((len(n) for n in names), default=0)
|
|
1615
1668
|
char_px = strain_label_font_size * _LABEL_CHAR_PX_RATIO
|
|
1616
1669
|
label_pad_px = max(_LABEL_PAD_PX_MIN, strain_label_font_size * _LABEL_PAD_RATIO)
|
|
1670
|
+
chart_gap_px = max(
|
|
1671
|
+
_LABEL_CHART_GAP_PX_MIN, strain_label_font_size * _LABEL_CHART_GAP_RATIO
|
|
1672
|
+
)
|
|
1617
1673
|
# Strip needs to fit the longest label plus `halo_px / 2` of halo
|
|
1618
|
-
# extension on the leader-facing side,
|
|
1619
|
-
|
|
1674
|
+
# extension on the leader-facing side, a pad between the tree and the
|
|
1675
|
+
# text, and a smaller gap between the text and the chart.
|
|
1676
|
+
label_pixel_width = (
|
|
1677
|
+
label_pad_px + chart_gap_px + max_name_len * char_px + halo_px / 2
|
|
1678
|
+
)
|
|
1620
1679
|
strip_pixel_width = label_pixel_width - shift_tree_loc
|
|
1621
1680
|
if strip_pixel_width <= 0:
|
|
1622
1681
|
raise ValueError(
|
|
@@ -1629,9 +1688,19 @@ def _build_tree_chart(
|
|
|
1629
1688
|
per_pixel = branch_span / tree_size if tree_size else 0.0
|
|
1630
1689
|
extra_branch_units = strip_pixel_width * per_pixel
|
|
1631
1690
|
chart_edge_branch = branch_max + extra_branch_units
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1691
|
+
# Anchor the labels `chart_gap_px` *inside* the chart-facing edge rather
|
|
1692
|
+
# than on it, so the text does not run up against the chart's frame. The
|
|
1693
|
+
# leaders stop at the same place, where the text begins.
|
|
1694
|
+
label_anchor_branch = chart_edge_branch - chart_gap_px * per_pixel
|
|
1695
|
+
tips_df = tips_df.assign(x_label=label_anchor_branch)
|
|
1696
|
+
leader_df = tips_df[tips_df["x"] < label_anchor_branch].assign(
|
|
1697
|
+
x2=label_anchor_branch
|
|
1698
|
+
)
|
|
1699
|
+
tips_df = tips_df.assign(
|
|
1700
|
+
**{
|
|
1701
|
+
_label_style.membership_column(i): tips_df["name"].isin(rule.strains)
|
|
1702
|
+
for i, rule in enumerate(label_style_rules or [])
|
|
1703
|
+
}
|
|
1635
1704
|
)
|
|
1636
1705
|
extended_tree_size = tree_size + strip_pixel_width
|
|
1637
1706
|
else:
|
|
@@ -1680,9 +1749,9 @@ def _build_tree_chart(
|
|
|
1680
1749
|
# When connect_leader_to_label is on, the branch domain is extended
|
|
1681
1750
|
# past `branch_max` to `chart_edge_branch` so the label strip has
|
|
1682
1751
|
# data-units to occupy; tips at `branch_max` still sit at pixel
|
|
1683
|
-
# `tree_size` on the panel. Each label's chart-facing edge
|
|
1684
|
-
#
|
|
1685
|
-
# tree on the left, left for tree on the right).
|
|
1752
|
+
# `tree_size` on the panel. Each label's chart-facing edge sits
|
|
1753
|
+
# `chart_gap_px` inside `chart_edge_branch`, aligned outward (right
|
|
1754
|
+
# for tree on the left, left for tree on the right).
|
|
1686
1755
|
if tree_location == "left":
|
|
1687
1756
|
branch_domain = [branch_min, chart_edge_branch]
|
|
1688
1757
|
text_align = "right"
|
|
@@ -1748,26 +1817,12 @@ def _build_tree_chart(
|
|
|
1748
1817
|
)
|
|
1749
1818
|
layers.append(
|
|
1750
1819
|
alt.Chart(tips_df)
|
|
1751
|
-
.mark_text(
|
|
1752
|
-
align=text_align,
|
|
1753
|
-
baseline="middle",
|
|
1754
|
-
fontSize=strain_label_font_size,
|
|
1755
|
-
fontWeight=strain_label_font_weight,
|
|
1756
|
-
fill="white",
|
|
1757
|
-
stroke="white",
|
|
1758
|
-
strokeWidth=halo_px,
|
|
1759
|
-
strokeJoin="round",
|
|
1760
|
-
)
|
|
1820
|
+
.mark_text(align=text_align, **label_halo_mark_kwargs)
|
|
1761
1821
|
.encode(**label_text_enc)
|
|
1762
1822
|
)
|
|
1763
1823
|
layers.append(
|
|
1764
1824
|
alt.Chart(tips_df)
|
|
1765
|
-
.mark_text(
|
|
1766
|
-
align=text_align,
|
|
1767
|
-
baseline="middle",
|
|
1768
|
-
fontSize=strain_label_font_size,
|
|
1769
|
-
fontWeight=strain_label_font_weight,
|
|
1770
|
-
)
|
|
1825
|
+
.mark_text(align=text_align, **label_text_mark_kwargs)
|
|
1771
1826
|
.encode(**label_text_enc)
|
|
1772
1827
|
)
|
|
1773
1828
|
if scale_bar_layer is not None:
|
|
@@ -1783,7 +1838,8 @@ def _build_tree_chart(
|
|
|
1783
1838
|
# strip at bottom, opposite the chart above).
|
|
1784
1839
|
# tree_location="bottom" → root at bottom → branch_max at top (label
|
|
1785
1840
|
# strip at top, opposite the chart below).
|
|
1786
|
-
# Each label's chart-facing edge
|
|
1841
|
+
# Each label's chart-facing edge sits `chart_gap_px` inside
|
|
1842
|
+
# `chart_edge_branch`.
|
|
1787
1843
|
# The text mark is rotated 270° (reads bottom-to-top), which maps
|
|
1788
1844
|
# pre-rotation `align="right"` to a top anchor (text extends down)
|
|
1789
1845
|
# and `align="left"` to a bottom anchor (text extends up). For tree
|
|
@@ -1853,28 +1909,12 @@ def _build_tree_chart(
|
|
|
1853
1909
|
)
|
|
1854
1910
|
layers.append(
|
|
1855
1911
|
alt.Chart(tips_df)
|
|
1856
|
-
.mark_text(
|
|
1857
|
-
align=text_align,
|
|
1858
|
-
baseline="middle",
|
|
1859
|
-
angle=270,
|
|
1860
|
-
fontSize=strain_label_font_size,
|
|
1861
|
-
fontWeight=strain_label_font_weight,
|
|
1862
|
-
fill="white",
|
|
1863
|
-
stroke="white",
|
|
1864
|
-
strokeWidth=halo_px,
|
|
1865
|
-
strokeJoin="round",
|
|
1866
|
-
)
|
|
1912
|
+
.mark_text(align=text_align, angle=270, **label_halo_mark_kwargs)
|
|
1867
1913
|
.encode(**label_text_enc)
|
|
1868
1914
|
)
|
|
1869
1915
|
layers.append(
|
|
1870
1916
|
alt.Chart(tips_df)
|
|
1871
|
-
.mark_text(
|
|
1872
|
-
align=text_align,
|
|
1873
|
-
baseline="middle",
|
|
1874
|
-
angle=270,
|
|
1875
|
-
fontSize=strain_label_font_size,
|
|
1876
|
-
fontWeight=strain_label_font_weight,
|
|
1877
|
-
)
|
|
1917
|
+
.mark_text(align=text_align, angle=270, **label_text_mark_kwargs)
|
|
1878
1918
|
.encode(**label_text_enc)
|
|
1879
1919
|
)
|
|
1880
1920
|
if scale_bar_layer is not None:
|
tree_annotated_plot/cli.py
CHANGED
|
@@ -74,19 +74,26 @@ class _ColorScaleParamType(click.ParamType):
|
|
|
74
74
|
_COLOR_SCALE_PARAM_TYPE = _ColorScaleParamType()
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
class
|
|
78
|
-
"""Parse a JSON
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
``'{"orient":"left","labelFontSize":13}'``).
|
|
83
|
-
|
|
77
|
+
class _JsonParamType(click.ParamType):
|
|
78
|
+
"""Parse a JSON string into a dict, or into a list of dicts.
|
|
79
|
+
|
|
80
|
+
`container=dict` expects a JSON object; used for
|
|
81
|
+
``--tree-color-legend-format``: any subset of Vega-Lite's Legend
|
|
82
|
+
properties (e.g. ``'{"orient":"left","labelFontSize":13}'``).
|
|
83
|
+
`container=list` expects a JSON array of objects; used for
|
|
84
|
+
``--strain-label-styles`` (e.g.
|
|
85
|
+
``'[{"strains":["K"],"style":{"fill":"red"}}]'``). The user must quote
|
|
86
|
+
the whole argument so the shell doesn't interpret braces or quotes.
|
|
87
|
+
`example` is shown in the error message when the JSON has the wrong shape.
|
|
84
88
|
"""
|
|
85
89
|
|
|
86
|
-
name
|
|
90
|
+
def __init__(self, container: type, *, name: str, example: str) -> None:
|
|
91
|
+
self.container = container
|
|
92
|
+
self.name = name
|
|
93
|
+
self.example = example
|
|
87
94
|
|
|
88
95
|
def convert(self, value, param, ctx): # type: ignore[override]
|
|
89
|
-
if value is None or isinstance(value,
|
|
96
|
+
if value is None or isinstance(value, self.container):
|
|
90
97
|
return value
|
|
91
98
|
text = str(value).strip()
|
|
92
99
|
if not text:
|
|
@@ -95,17 +102,34 @@ class _JsonDictParamType(click.ParamType):
|
|
|
95
102
|
parsed = json.loads(text)
|
|
96
103
|
except json.JSONDecodeError as exc:
|
|
97
104
|
self.fail(f"invalid JSON: {exc}", param, ctx)
|
|
98
|
-
if
|
|
105
|
+
kind = "object" if self.container is dict else "array of objects"
|
|
106
|
+
if not isinstance(parsed, self.container):
|
|
99
107
|
self.fail(
|
|
100
|
-
f
|
|
108
|
+
f"expected a JSON {kind} (e.g. {self.example}); "
|
|
101
109
|
f"got {type(parsed).__name__}.",
|
|
102
110
|
param,
|
|
103
111
|
ctx,
|
|
104
112
|
)
|
|
113
|
+
if self.container is list:
|
|
114
|
+
for i, item in enumerate(parsed):
|
|
115
|
+
if not isinstance(item, dict):
|
|
116
|
+
self.fail(
|
|
117
|
+
f"expected a JSON {kind} (e.g. {self.example}); "
|
|
118
|
+
f"element {i} is {type(item).__name__}.",
|
|
119
|
+
param,
|
|
120
|
+
ctx,
|
|
121
|
+
)
|
|
105
122
|
return parsed
|
|
106
123
|
|
|
107
124
|
|
|
108
|
-
_JSON_DICT_PARAM_TYPE =
|
|
125
|
+
_JSON_DICT_PARAM_TYPE = _JsonParamType(
|
|
126
|
+
dict, name="json_dict", example='\'{"orient":"left"}\''
|
|
127
|
+
)
|
|
128
|
+
_JSON_LIST_PARAM_TYPE = _JsonParamType(
|
|
129
|
+
list,
|
|
130
|
+
name="json_list",
|
|
131
|
+
example='\'[{"strains":["K"],"style":{"fill":"red"}}]\'',
|
|
132
|
+
)
|
|
109
133
|
|
|
110
134
|
|
|
111
135
|
def _is_str_dict(tp: Any) -> bool:
|
|
@@ -126,6 +150,14 @@ def _is_dict_any(tp: Any) -> bool:
|
|
|
126
150
|
return False
|
|
127
151
|
|
|
128
152
|
|
|
153
|
+
def _is_list_of_dicts(tp: Any) -> bool:
|
|
154
|
+
"""Return True for `list[dict[str, Any]]`."""
|
|
155
|
+
if get_origin(tp) is list:
|
|
156
|
+
args = get_args(tp)
|
|
157
|
+
return len(args) == 1 and _is_dict_any(args[0])
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
129
161
|
def _option_for_field(field: dataclasses.Field, hints: dict) -> Any:
|
|
130
162
|
"""Build a `click.option(...)` decorator from a PlotConfig field.
|
|
131
163
|
|
|
@@ -188,6 +220,16 @@ def _option_for_field(field: dataclasses.Field, hints: dict) -> Any:
|
|
|
188
220
|
show_default=False,
|
|
189
221
|
)
|
|
190
222
|
|
|
223
|
+
# list[dict[str, Any]] (with or without `| None`) → JSON-array parser.
|
|
224
|
+
if _is_list_of_dicts(real_type):
|
|
225
|
+
return click.option(
|
|
226
|
+
cli_name,
|
|
227
|
+
type=_JSON_LIST_PARAM_TYPE,
|
|
228
|
+
default=field.default if has_default else None,
|
|
229
|
+
help=description,
|
|
230
|
+
show_default=False,
|
|
231
|
+
)
|
|
232
|
+
|
|
191
233
|
# Optional / Union — unwrap to the non-None branch.
|
|
192
234
|
origin = get_origin(real_type)
|
|
193
235
|
if origin in (typing.Union, types.UnionType):
|
|
@@ -210,6 +252,14 @@ def _option_for_field(field: dataclasses.Field, hints: dict) -> Any:
|
|
|
210
252
|
help=description,
|
|
211
253
|
show_default=False,
|
|
212
254
|
)
|
|
255
|
+
if _is_list_of_dicts(inner):
|
|
256
|
+
return click.option(
|
|
257
|
+
cli_name,
|
|
258
|
+
type=_JSON_LIST_PARAM_TYPE,
|
|
259
|
+
default=field.default if has_default else None,
|
|
260
|
+
help=description,
|
|
261
|
+
show_default=False,
|
|
262
|
+
)
|
|
213
263
|
if get_origin(inner) is Literal:
|
|
214
264
|
choices = [str(c) for c in get_args(inner)]
|
|
215
265
|
return click.option(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: tree-annotated-plot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
|
|
5
5
|
Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
|
|
6
6
|
Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
|
|
@@ -43,7 +43,7 @@ Requires-Dist: vl-convert-python>=1.9
|
|
|
43
43
|
Provides-Extra: dev
|
|
44
44
|
Requires-Dist: black>=24; extra == 'dev'
|
|
45
45
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
46
|
-
Requires-Dist: ruff
|
|
46
|
+
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
|
|
47
47
|
Provides-Extra: docs
|
|
48
48
|
Requires-Dist: mkdocs-click>=0.8; extra == 'docs'
|
|
49
49
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tree_annotated_plot/__init__.py,sha256=R39gjVnl3K2VjLba6zwtwPGADFmtNOE2UYNk1y9_z6k,351
|
|
2
|
+
tree_annotated_plot/_color.py,sha256=nOZp-KIPy6s-6gBkzW36oHrwDk_SmvVNsMiIJ-dY1Io,26519
|
|
3
|
+
tree_annotated_plot/_config.py,sha256=sDFaOoTcQ87IG7Yx4bOnWsv44uPHHhk_wvA4xeQ-NoA,15412
|
|
4
|
+
tree_annotated_plot/_label_style.py,sha256=dyayJZeXT7TvldsRvEP-LhZgqJJoez_luqkk8lRAPvE,13281
|
|
5
|
+
tree_annotated_plot/_plot.py,sha256=k8CygwCWa9Fl-EANDzAO-LfE3Wjj2pC0-PwHDIjt7JI,79668
|
|
6
|
+
tree_annotated_plot/_tree.py,sha256=Bj-ABt8aooSwV7wAVTbwFGMmCnTPGOKweUWAdfb7aEI,13563
|
|
7
|
+
tree_annotated_plot/cli.py,sha256=Q8dtm3vWmAOJYwu4mrlrYsO0Ys59HYCyKBtBHIryvKo,11804
|
|
8
|
+
tree_annotated_plot-0.5.0.dist-info/METADATA,sha256=WyWmNl77MJQBDk8yWZiJJfvSMyaGu4S8O0qt2tpbICQ,11462
|
|
9
|
+
tree_annotated_plot-0.5.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
tree_annotated_plot-0.5.0.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
|
|
11
|
+
tree_annotated_plot-0.5.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
|
|
12
|
+
tree_annotated_plot-0.5.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
tree_annotated_plot/__init__.py,sha256=R39gjVnl3K2VjLba6zwtwPGADFmtNOE2UYNk1y9_z6k,351
|
|
2
|
-
tree_annotated_plot/_color.py,sha256=nOZp-KIPy6s-6gBkzW36oHrwDk_SmvVNsMiIJ-dY1Io,26519
|
|
3
|
-
tree_annotated_plot/_config.py,sha256=Xs9XfuPAaIhGuffYbsDOfcJW_f4etpgPW4ombHNuIAg,12630
|
|
4
|
-
tree_annotated_plot/_plot.py,sha256=a-sxcZ83Sp-bc0swwisOlvhV7L6bhJQWeh5kppANMX0,76901
|
|
5
|
-
tree_annotated_plot/_tree.py,sha256=Bj-ABt8aooSwV7wAVTbwFGMmCnTPGOKweUWAdfb7aEI,13563
|
|
6
|
-
tree_annotated_plot/cli.py,sha256=OG6Qmzk5BaHUNrfOopX20FbzBN2ahr_SBxHpaCk6Z5c,9848
|
|
7
|
-
tree_annotated_plot-0.3.0.dist-info/METADATA,sha256=oQGwnxJxdq3g_fx_gfn6mjsskj0RHpBcMw2pGcJNZPo,11455
|
|
8
|
-
tree_annotated_plot-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
-
tree_annotated_plot-0.3.0.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
|
|
10
|
-
tree_annotated_plot-0.3.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
|
|
11
|
-
tree_annotated_plot-0.3.0.dist-info/RECORD,,
|
{tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{tree_annotated_plot-0.3.0.dist-info → tree_annotated_plot-0.5.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|