tree-annotated-plot 0.4.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.
@@ -161,6 +161,28 @@ class PlotConfig:
161
161
  "when `connect_leader_to_label` is on.",
162
162
  ] = "normal"
163
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
+
164
186
  shift_tree_loc: Annotated[
165
187
  int,
166
188
  "Pixels by which to shift the tree toward (positive) or away from "
@@ -225,10 +247,25 @@ class PlotConfig:
225
247
 
226
248
 
227
249
  # Sidecar for Python-docstring-only prose, keyed by PlotConfig field name.
228
- # Empty by default — add an entry when a field's docstring entry needs more
229
- # than the CLI `--help` text covers. The extras are appended to the field's
230
- # description in `tree_annotated_plot.plot.__doc__`, never in CLI --help.
231
- 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
+ }
232
269
 
233
270
 
234
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)
@@ -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
@@ -61,6 +61,7 @@ def plot(
61
61
  connect_leader_to_label: bool = False,
62
62
  strain_label_font_size: float = 10.0,
63
63
  strain_label_font_weight: Literal["normal", "bold"] = "normal",
64
+ strain_label_styles: list[dict[str, Any]] | None = None,
64
65
  shift_tree_loc: int = 0,
65
66
  color_tree_by: str | None = None,
66
67
  tree_color_scale: dict[str, str] | None = None,
@@ -90,6 +91,7 @@ def plot(
90
91
  connect_leader_to_label=connect_leader_to_label,
91
92
  strain_label_font_size=strain_label_font_size,
92
93
  strain_label_font_weight=strain_label_font_weight,
94
+ strain_label_styles=strain_label_styles,
93
95
  shift_tree_loc=shift_tree_loc,
94
96
  color_tree_by=color_tree_by,
95
97
  tree_color_scale=tree_color_scale,
@@ -124,10 +126,12 @@ _PLOT_DOC_FOOTER = """
124
126
  Returns
125
127
  -------
126
128
  altair.HConcatChart | altair.VConcatChart
127
- For vertical layout (chart strain on `y`), an `HConcatChart` with the
128
- tree on the left and the user's chart on the right. For horizontal
129
- layout (chart strain on `x`), a `VConcatChart` with the tree on top
130
- and the chart below.
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"`.
131
135
  """
132
136
 
133
137
 
@@ -149,6 +153,14 @@ def _build(
149
153
  set on a config object); `config` carries every styling / behavior
150
154
  knob. Both surfaces converge here so they can never disagree.
151
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
+ )
152
164
  root, auspice_meta = _ensure_tree(
153
165
  tree,
154
166
  config.tree_strain_field,
@@ -157,6 +169,9 @@ def _build(
157
169
  )
158
170
  tip_list = _tree.layout(root)
159
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)
160
175
 
161
176
  _check_no_duplicate_tip_strains(
162
177
  tip_names, tree_strain_field=config.tree_strain_field
@@ -208,6 +223,14 @@ def _build(
208
223
  tip_list = _tree.layout(root)
209
224
  tip_names = [t.name for t in tip_list]
210
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
+
211
234
  strain_dim = _coerce_dim(
212
235
  _chart_strain_dim(spec, axis_hits, axis), n_tips=len(tip_names)
213
236
  )
@@ -242,6 +265,7 @@ def _build(
242
265
  connect_leader_to_label=config.connect_leader_to_label,
243
266
  strain_label_font_size=config.strain_label_font_size,
244
267
  strain_label_font_weight=config.strain_label_font_weight,
268
+ label_style_rules=label_style_rules,
245
269
  shift_tree_loc=config.shift_tree_loc,
246
270
  tip_names=tip_names,
247
271
  color_mapping=color_mapping,
@@ -291,6 +315,15 @@ def _build(
291
315
  _apply_combined_config(combined, hoisted_config)
292
316
  for k, v in hoisted_other.items():
293
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
+ ]
294
327
  return combined
295
328
 
296
329
 
@@ -390,9 +423,15 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve
390
423
  A live `alt.TopLevelMixin` is returned as-is — its `$schema` is
391
424
  irrelevant since the constructing altair version is necessarily the
392
425
  running altair version. For dict / JSON path / HTML path inputs we run
393
- the Vega-Lite version check (under `strict_version`), strip altair's
394
- `params[].views` round-trip annotations, and then dispatch to the right
395
- chart subclass via `alt.Chart.from_dict(...)`.
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.
396
435
  """
397
436
  if isinstance(chart_input, alt.TopLevelMixin):
398
437
  return chart_input
@@ -420,8 +459,9 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve
420
459
  )
421
460
 
422
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.
423
464
  spec = copy.deepcopy(spec)
424
- _strip_params_views(spec)
425
465
  # `from_dict` with validate=True dispatches to the right subclass
426
466
  # (Chart / LayerChart / FacetChart / HConcatChart / VConcatChart /
427
467
  # ConcatChart) based on the spec's shape.
@@ -485,26 +525,6 @@ def _check_chart_schema_version(spec: dict, *, strict_version: bool) -> None:
485
525
  )
486
526
 
487
527
 
488
- def _strip_params_views(spec: Any) -> None:
489
- """Recursively delete `views` from every params[] entry.
490
-
491
- altair's `to_dict()` annotates each selection-param with the views it's
492
- bound to, but altair's own `from_dict()` validation rejects the field.
493
- Stripping is safe: the renderer rebinds params to views on its own.
494
- """
495
- if isinstance(spec, dict):
496
- params = spec.get("params")
497
- if isinstance(params, list):
498
- for p in params:
499
- if isinstance(p, dict):
500
- p.pop("views", None)
501
- for v in spec.values():
502
- _strip_params_views(v)
503
- elif isinstance(spec, list):
504
- for item in spec:
505
- _strip_params_views(item)
506
-
507
-
508
528
  class _ScriptCollector(HTMLParser):
509
529
  """Collect the text content of every <script> tag in an HTML document."""
510
530
 
@@ -1508,6 +1528,7 @@ def _build_tree_chart(
1508
1528
  connect_leader_to_label: bool = False,
1509
1529
  strain_label_font_size: float = 10.0,
1510
1530
  strain_label_font_weight: str = "normal",
1531
+ label_style_rules: list[_label_style.LabelStyleRule] | None = None,
1511
1532
  shift_tree_loc: int = 0,
1512
1533
  tip_names: list[str] | None = None,
1513
1534
  color_mapping: _color.ColorMapping | None = None,
@@ -1613,6 +1634,10 @@ def _build_tree_chart(
1613
1634
  # text — and masks the dashed leader behind each label without any
1614
1635
  # width estimation. Vega-Lite doesn't expose `paintOrder`, so the
1615
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.
1616
1641
  # - `shift_tree_loc` (pixels) shrinks the strip — bringing the tree
1617
1642
  # visually closer to the labels — by reducing the data-units between
1618
1643
  # branch_max and chart_edge_branch. The tree's tree_size-pixel
@@ -1620,6 +1645,23 @@ def _build_tree_chart(
1620
1645
  # When connect_leader_to_label is off, the chart's natural strain-axis
1621
1646
  # labels are kept and leaders stop at branch_max (the prior behavior).
1622
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}
1623
1665
  if connect_leader_to_label:
1624
1666
  names = tip_names if tip_names is not None else list(tips_df["name"])
1625
1667
  max_name_len = max((len(n) for n in names), default=0)
@@ -1654,6 +1696,12 @@ def _build_tree_chart(
1654
1696
  leader_df = tips_df[tips_df["x"] < label_anchor_branch].assign(
1655
1697
  x2=label_anchor_branch
1656
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
+ }
1704
+ )
1657
1705
  extended_tree_size = tree_size + strip_pixel_width
1658
1706
  else:
1659
1707
  chart_edge_branch = branch_max
@@ -1769,26 +1817,12 @@ def _build_tree_chart(
1769
1817
  )
1770
1818
  layers.append(
1771
1819
  alt.Chart(tips_df)
1772
- .mark_text(
1773
- align=text_align,
1774
- baseline="middle",
1775
- fontSize=strain_label_font_size,
1776
- fontWeight=strain_label_font_weight,
1777
- fill="white",
1778
- stroke="white",
1779
- strokeWidth=halo_px,
1780
- strokeJoin="round",
1781
- )
1820
+ .mark_text(align=text_align, **label_halo_mark_kwargs)
1782
1821
  .encode(**label_text_enc)
1783
1822
  )
1784
1823
  layers.append(
1785
1824
  alt.Chart(tips_df)
1786
- .mark_text(
1787
- align=text_align,
1788
- baseline="middle",
1789
- fontSize=strain_label_font_size,
1790
- fontWeight=strain_label_font_weight,
1791
- )
1825
+ .mark_text(align=text_align, **label_text_mark_kwargs)
1792
1826
  .encode(**label_text_enc)
1793
1827
  )
1794
1828
  if scale_bar_layer is not None:
@@ -1875,28 +1909,12 @@ def _build_tree_chart(
1875
1909
  )
1876
1910
  layers.append(
1877
1911
  alt.Chart(tips_df)
1878
- .mark_text(
1879
- align=text_align,
1880
- baseline="middle",
1881
- angle=270,
1882
- fontSize=strain_label_font_size,
1883
- fontWeight=strain_label_font_weight,
1884
- fill="white",
1885
- stroke="white",
1886
- strokeWidth=halo_px,
1887
- strokeJoin="round",
1888
- )
1912
+ .mark_text(align=text_align, angle=270, **label_halo_mark_kwargs)
1889
1913
  .encode(**label_text_enc)
1890
1914
  )
1891
1915
  layers.append(
1892
1916
  alt.Chart(tips_df)
1893
- .mark_text(
1894
- align=text_align,
1895
- baseline="middle",
1896
- angle=270,
1897
- fontSize=strain_label_font_size,
1898
- fontWeight=strain_label_font_weight,
1899
- )
1917
+ .mark_text(align=text_align, angle=270, **label_text_mark_kwargs)
1900
1918
  .encode(**label_text_enc)
1901
1919
  )
1902
1920
  if scale_bar_layer is not None:
@@ -74,19 +74,26 @@ class _ColorScaleParamType(click.ParamType):
74
74
  _COLOR_SCALE_PARAM_TYPE = _ColorScaleParamType()
75
75
 
76
76
 
77
- class _JsonDictParamType(click.ParamType):
78
- """Parse a JSON-object string into a dict.
79
-
80
- Used for ``--tree-color-legend-format``: any subset of Vega-Lite's
81
- Legend properties as a JSON object (e.g.
82
- ``'{"orient":"left","labelFontSize":13}'``). The user must quote the
83
- whole argument so the shell doesn't interpret braces or quotes.
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 = "json_dict"
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, dict):
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 not isinstance(parsed, dict):
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'expected a JSON object (e.g. \'{{"orient":"left"}}\'); '
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 = _JsonDictParamType()
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
1
  Metadata-Version: 2.5
2
2
  Name: tree-annotated-plot
3
- Version: 0.4.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
@@ -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,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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=0ib600JaMpMz4ZjFYtvFu3nhUhliJRexTJcFpDKVOD0,13171
4
- tree_annotated_plot/_plot.py,sha256=pqhR9PgQUML2-taP_k1FEidpIdBgJ6tulMEpFJbD8WI,77969
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.4.0.dist-info/METADATA,sha256=JQNY2-mmZIQA7TjABRX_YkvoZXxqix-Ixs5n23EQ1FY,11462
8
- tree_annotated_plot-0.4.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
- tree_annotated_plot-0.4.0.dist-info/entry_points.txt,sha256=K7edTocRbwmtM2OeZmCy3DLjmY3MR-q2EJmQQq4rj0E,69
10
- tree_annotated_plot-0.4.0.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
11
- tree_annotated_plot-0.4.0.dist-info/RECORD,,