diff --git a/CHANGELOG.md b/CHANGELOG.md index bf702ae..7fb18eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2026-10-30 + +### Added + +- `strain_label_styles` (default `None`): style a subset of the strain labels + rendered in the tree panel when `connect_leader_to_label` is on, e.g. to draw + the vaccine strains' labels in bold red. A list of rules, each naming some + `strains` and a `style` of Vega-Lite text properties (`fill`, `opacity`, + `fontWeight`, `fontStyle`). Where rules overlap, later rules win property by + property. An optional `when` condition applies a rule only while it holds: + - the name of a parameter declared in the chart, such as a checkbox; + - any Vega expression, e.g. `"emphasis == 'cell'"` for a radio button; + - in Python, an `alt.Parameter`, which is declared on the returned chart if + the chart doesn't already declare it. + + Each styled property becomes a per-label expression on the existing label + layers, so no layers are added and the returned chart keeps its shape. A + strain that isn't a tree tip raises; strains dropped by `prune_tree_to_chart` + are skipped. CLI form: `--strain-label-styles` with a JSON array, where + `when` names a parameter saved in the chart JSON. + ([#12](https://github.com/jbloomlab/tree-annotated-plot/issues/12)) + +### Fixed + +- A chart with selection parameters on a compound chart (layer, facet, or + concat) failed to render when passed to `plot()` as a JSON path, HTML path + or spec dict, which is every CLI use. Vega-Lite reported + `Duplicate signal name: "_tuple"`. This affected the flu examples' + CLI reproductions. The chart loader removed each selection's `views` before + loading, and altair then emitted empty `views`, which makes Vega-Lite define + the selection in every view. The loader now leaves the spec's `views` intact. + Live altair charts were never affected. +- The `plot()` docstring's Returns section described the panel order wrongly + for a strain on `x`. The default `tree_location="bottom"` puts the chart + first and the tree second. The section now gives the order for each + `tree_location`. ## [0.4.0] - 2026-08-20 diff --git a/CLAUDE.md b/CLAUDE.md index f9fa2fe..b35af8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,9 @@ section as you make them — new parameters, behavior changes, removed features, bug fixes — in [Keep a Changelog](https://keepachangelog.com/) format. At release time, `## [Unreleased]` is renamed to - `## [X.Y.Z] - YYYY-MM-DD` and a fresh `## [Unreleased]` is created. + `## [X.Y.Z] - YYYY-MM-DD`, with no empty `## [Unreleased]` left above it. + When the next change lands and there's no `## [Unreleased]` section, add + one at the top. - **Single source of truth — `pyproject.toml`**: canonical for dependencies, supported Python version, build config, tool settings. Don't restate any of these in prose; refer to `pyproject.toml`. @@ -52,6 +54,13 @@ on `x` or `y`, overrides its sort to match the tree's tip order, and hconcat's or vconcat's a tree panel on the matching side. Tree dictates tip ordering; plot follows. +- **Loaded specs go to `from_dict` unmodified.** Don't strip or rewrite + altair's annotations when loading a dict / JSON / HTML chart. Selection + params' `views` in particular are valid at the top level; removing them made + every compound chart with a selection fail to render ("Duplicate signal + name"). See `_load_chart`. Likewise, declare extra params by appending to + the combined chart's top-level `params`, never with `add_params`, which + recurses into sub-charts and raises on spec-loaded ones. - **Two surfaces, one config**: `tree_annotated_plot.plot()` and the `tree-annotated-plot` CLI both accept the same parameters via `PlotConfig`. They converge on `_build(tree, chart, config)` so they can never disagree. @@ -78,7 +87,10 @@ - **Eyeball examples after any rendering change.** There are no image-snapshot regression tests (vl-convert/font/platform variance makes them flaky), so visual regressions only get caught by - looking. After any change to `_plot.py`, `_tree.py`, the example + looking. Tests may still render with `vl_convert.vegalite_to_svg` + to check that a spec renders at all, or to assert discrete SVG + attributes such as a label's `font-weight` / `fill`; those are + deterministic. After any change to `_plot.py`, `_tree.py`, the example scripts, or anything they touch: run `scripts/build_docs.sh`, open `site/examples.html`, and click through each example — both the embedded SVG and the interactive HTML link. Confirm tip-row diff --git a/docs/examples.md b/docs/examples.md index 0df0d6d..c04155b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -140,6 +140,77 @@ CLI flags: `--connect-leader-to-label --strain-label-font-size 9 --shift-tree-loc 60`. In Python: `connect_leader_to_label=True, strain_label_font_size=9, shift_tree_loc=60`. +### Optional: emphasize some strains' labels + +With label connection on, you can also style a subset of the labels, for +example so the vaccine strains are easy to pick out. Pass +`strain_label_styles`, a list of rules. Each rule names some `strains` +(tree tip identifiers, i.e. `tree_strain_field` values) and a `style` built +from Vega-Lite text properties: `fill`, `opacity`, `fontWeight` and +`fontStyle`. A rule can also take a `when` condition, usually the name of a +checkbox parameter, so the style applies only while the box is ticked. +Where rules overlap, later rules win. + +The example below draws the labels of the H3N2 recent vaccine strains in +bold red, with a checkbox that turns the emphasis off and on: + +![H3N2 with vaccine-strain labels in bold red](images/h3n2_combined_label_styles.svg) + +[Open the interactive chart in a new tab →](charts/h3n2_combined_label_styles.html){target="_blank"} + +From the CLI, a `when` parameter has to be declared in the saved chart, so +add it before saving: + +```python +chart = chart.add_params( + alt.param( + name="vaccine_strains", + value=True, + bind=alt.binding_checkbox(name="highlight vaccine strains "), + ) +) +chart.save("titers.json") +``` + +then pass the rules as a JSON array, alongside the label-connection flags +above: + +```bash +--connect-leader-to-label --strain-label-styles \ + '[{"strains": ["J.2.4", "J.2:S145N"], + "style": {"fill": "#d62728", "fontWeight": "bold"}, + "when": "vaccine_strains"}]' +``` + +In Python, pass the parameter object itself. If your chart doesn't already +declare it, it is declared on the returned chart for you: + +```python +vaccine_strains = alt.param( + name="vaccine_strains", + value=True, + bind=alt.binding_checkbox(name="highlight vaccine strains "), +) +tree_annotated_plot.plot( + ..., + connect_leader_to_label=True, + strain_label_styles=[ + { + "strains": ["J.2.4", "J.2:S145N"], + "style": {"fill": "#d62728", "fontWeight": "bold"}, + "when": vaccine_strains, + } + ], +) +``` + +Leave out `when` for a style that is always on. `when` can be any Vega +expression, so other inputs work too. For example, with a radio button +`alt.param(name="emphasis", value="cell", bind=alt.binding_radio(options=["cell", "egg"]))` +declared on the chart, one rule with `"when": "emphasis == 'cell'"` and +another with `"when": "emphasis == 'egg'"` switch the emphasis between two +groups of strains. + ### Color the tree Pass `color_tree_by` to color the tree's branches and tip circles by diff --git a/examples/flu-seqneut-2025to2026_titer_charts.py b/examples/flu-seqneut-2025to2026_titer_charts.py index f899586..c99abb7 100644 --- a/examples/flu-seqneut-2025to2026_titer_charts.py +++ b/examples/flu-seqneut-2025to2026_titer_charts.py @@ -131,6 +131,17 @@ def strain_plot_order(viruses_subset: pd.DataFrame) -> list[str]: return sorted_df["virus"].tolist() +def vaccine_axis_labels(viruses: pd.DataFrame, subtype: str) -> list[str]: + """Axis labels of the `RECENT_VACCINE_STRAINS` in `subtype`, in table order. + + These are the strain-axis values the chart (and so the tree's tips) use for + the vaccine strains, e.g. for `strain_label_styles`.""" + is_vaccine = (viruses["subtype"] == subtype) & viruses["virus"].isin( + RECENT_VACCINE_STRAINS + ) + return viruses.loc[is_vaccine, "axis_label"].tolist() + + def make_chart( subtype: str, chart_type: str, diff --git a/pyproject.toml b/pyproject.toml index 69bc7cf..6083975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tree-annotated-plot" -version = "0.4.0" +version = "0.5.0" description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/generate_docs_assets.py b/scripts/generate_docs_assets.py index 89f18f9..0532ffa 100644 --- a/scripts/generate_docs_assets.py +++ b/scripts/generate_docs_assets.py @@ -25,6 +25,8 @@ from pathlib import Path from types import ModuleType +import altair as alt + import tree_annotated_plot REPO = Path(__file__).resolve().parent.parent @@ -163,6 +165,47 @@ def _render_kikawa() -> None: ) _save_pair(out, "h3n2_combined_label_connect") + # The same label-connect H3N2 chart, with the recent vaccine strains' + # labels drawn in bold red while a checkbox is ticked: `strain_label_styles` + # with a `when` param. The checkbox starts ticked so the static SVG shows + # the emphasis. + h3n2_chart_styles = builder.make_chart( + subtype="H3N2", + chart_type="iqr", + titers=titers, + viruses=viruses, + metadata=metadata, + all_cohorts=all_cohorts, + ) + vaccine_strains = alt.param( + name="vaccine_strains", + value=True, + bind=alt.binding_checkbox(name="highlight vaccine strains "), + ) + out = tree_annotated_plot.plot( + DATA_DIR / "flu-seqneut-2025to2026_H3N2.json", + h3n2_chart_styles, + chart_strain_field="axis_label", + tree_strain_field="derived_haplotype", + branch_length="div", + tree_size=140, + scale_bar=True, + branch_length_units="substitutions", + connect_leader_to_label=True, + strain_label_font_size=9, + shift_tree_loc=60, + color_tree_by="subclade", + tree_color_legend_format={"orient": "left"}, + strain_label_styles=[ + { + "strains": builder.vaccine_axis_labels(viruses, "H3N2"), + "style": {"fill": "#d62728", "fontWeight": "bold"}, + "when": vaccine_strains, + } + ], + ) + _save_pair(out, "h3n2_combined_label_styles") + # H3N2 once more, colored by genotype at HA1 site 158: same chart and # default layout as `h3n2_combined`, with `color_tree_by` switched to # the genotype form. Site 158 has two mutations (N158K, N158D) in the diff --git a/src/tree_annotated_plot/_config.py b/src/tree_annotated_plot/_config.py index cbfbd7c..9679818 100644 --- a/src/tree_annotated_plot/_config.py +++ b/src/tree_annotated_plot/_config.py @@ -161,6 +161,28 @@ class PlotConfig: "when `connect_leader_to_label` is on.", ] = "normal" + strain_label_styles: Annotated[ + list[dict[str, Any]] | None, + "Style a subset of the strain labels rendered in the tree panel; " + "requires `connect_leader_to_label`. A list of rules, each a dict " + 'with keys "strains" (a list of tip identifiers, i.e. ' + '`tree_strain_field` values), "style" and, optionally, "when". ' + '"style" holds Vega-Lite text-mark properties: "fill" (a color), ' + '"opacity" (0 to 1), "fontWeight" ("normal", "bold", or 100-900) ' + 'and "fontStyle" ("normal" or "italic"). "when" is a Vega ' + "expression that turns the rule on while it is truthy, usually just " + "the name of a parameter declared in the chart (e.g. one bound to a " + "checkbox), but any expression works (e.g. \"emphasis == 'cell'\" " + 'for a radio button). Without "when" the style always applies. ' + "Where rules overlap, later rules win property by property; " + "properties no rule sets keep `strain_label_font_weight` and the " + "default black, upright text. A strain that isn't a tree tip is an " + "error; strains dropped by `prune_tree_to_chart` are skipped. CLI " + "form: a JSON array (quote the whole argument), e.g. " + '\'[{"strains": ["K"], "style": {"fill": "#d62728", ' + '"fontWeight": "bold"}, "when": "vaccine_strains"}]\'.', + ] = None + shift_tree_loc: Annotated[ int, "Pixels by which to shift the tree toward (positive) or away from " @@ -225,10 +247,25 @@ class PlotConfig: # Sidecar for Python-docstring-only prose, keyed by PlotConfig field name. -# Empty by default — add an entry when a field's docstring entry needs more -# than the CLI `--help` text covers. The extras are appended to the field's -# description in `tree_annotated_plot.plot.__doc__`, never in CLI --help. -PARAM_DOC_EXTRAS: dict[str, str] = {} +# Add an entry when a field's docstring entry needs more than the CLI +# `--help` text covers. The extras are appended to the field's description in +# `tree_annotated_plot.plot.__doc__`, never in CLI --help. +PARAM_DOC_EXTRAS: dict[str, str] = { + "strain_label_styles": ( + 'In Python, "when" may also be an `altair.Parameter`, e.g. ' + '`alt.param(name="vaccine_strains", value=True, ' + "bind=alt.binding_checkbox())`, which is declared on the returned " + "chart if your chart doesn't already declare it; or an altair " + 'parameter expression such as `emphasis == "cell"`. Only a bare ' + "`altair.Parameter` is declared for you: parameters that an " + "expression refers to must be declared on the chart, and an " + "expression other than a bare name isn't checked until the chart " + "renders. A bare name or parameter must be a variable parameter: " + "selection parameters are rejected, since the label data doesn't " + "carry the fields a selection tests. Any iterable of strings (e.g. " + 'a pandas Series) works for "strains".' + ), +} # Descriptions for the three data-input parameters. These can't live on diff --git a/src/tree_annotated_plot/_label_style.py b/src/tree_annotated_plot/_label_style.py new file mode 100644 index 0000000..0a6a6e9 --- /dev/null +++ b/src/tree_annotated_plot/_label_style.py @@ -0,0 +1,349 @@ +"""Per-strain styling of the tree-panel strain labels (`strain_label_styles`). + +Public entry points: :func:`resolve_rules` validates and normalizes the +user's rules, and :func:`mark_props` turns them into label mark properties. + +Each rule names some strains, a style (a subset of Vega-Lite text-mark +properties) and, optionally, a `when` condition. Every property a rule sets +becomes one `alt.ExprRef` on the existing label mark(s), evaluated per label: +Vega-Lite compiles a mark-level ``{"expr": ...}`` into the Vega encode block, +where ``datum`` is the label's row. That is why a property with no encoding +channel (``fontWeight``, ``fontStyle``) can still vary by strain without +duplicating the label layers. Which rules a label belongs to is carried in +one boolean column per rule (:func:`membership_column`) on the label data. + +``fontSize`` is deliberately not a style key: the label strip's width is +estimated from `strain_label_font_size`, so a per-strain size would need a +different layout calculation. +""" + +from __future__ import annotations + +import json +import math +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import Any + +import altair as alt +from altair.expr.core import Expression + +# Allowed `style` keys, in the order their expressions are emitted. +STYLE_KEYS = ("fill", "opacity", "fontWeight", "fontStyle") + +# Style keys that change the glyph outline. The white halo layer drawn under +# each label has to follow these so it keeps masking the leader exactly where +# the text is; `fill` and `opacity` apply to the visible text only. +_GLYPH_KEYS = frozenset({"fontWeight", "fontStyle"}) + +_RULE_KEYS = frozenset({"strains", "style", "when"}) + +_FONT_WEIGHTS = ("normal", "bold") +_NUMERIC_FONT_WEIGHTS = tuple(range(100, 1000, 100)) +_FONT_STYLES = ("normal", "italic") + +# Hints for style keys a user is likely to reach for that we don't accept. +_STYLE_KEY_HINTS = { + "color": 'use "fill" to set the label color', + "fontSize": ( + "per-strain font sizes are not supported because they change the " + "label-strip layout; use `strain_label_font_size` for all labels" + ), +} + +# A bare Vega expression identifier, i.e. what a param name looks like. +_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*") + + +@dataclass(frozen=True) +class LabelStyleRule: + """One validated `strain_label_styles` rule. + + `strains` holds only the strains drawn in the tree (pruned ones are + dropped); `when` is a Vega expression string, or None for always-on. + """ + + strains: frozenset[str] + style: dict[str, Any] + when: str | None + + +def membership_column(index: int) -> str: + """Name of the boolean label-data column marking rule `index`'s strains.""" + return f"_tap_label_style_{index}" + + +def resolve_rules( + raw: Any, + *, + input_tip_names: Iterable[str], + drawn_tip_names: Iterable[str], + chart_spec: dict, + tree_strain_field: str, +) -> tuple[list[LabelStyleRule], list[alt.Parameter]]: + """Validate `strain_label_styles` and return ``(rules, params_to_declare)``. + + `input_tip_names` are the tree's tips before `prune_tree_to_chart`; a + strain outside them is an error. `drawn_tip_names` are the tips left + after pruning; strains pruned away are silently dropped from their rule. + `params_to_declare` lists the `alt.Parameter` objects passed as `when` + that the chart doesn't already declare, deduplicated by name. + """ + if raw is None: + return [], [] + if isinstance(raw, (str, bytes, Mapping)) or not isinstance(raw, Iterable): + raise ValueError( + "strain_label_styles must be a list of rule dicts, each with " + f'"strains" and "style" keys; got {type(raw).__name__}.' + ) + raw_rules = list(raw) + if not raw_rules: + return [], [] + + input_tips = set(input_tip_names) + drawn_tips = set(drawn_tip_names) + declared = _declared_params(chart_spec) + rule_params: dict[str, alt.Parameter] = {} + for rule in raw_rules: + when = rule.get("when") if isinstance(rule, Mapping) else None + if isinstance(when, alt.Parameter): + rule_params.setdefault(_param_name(when), when) + + rules: list[LabelStyleRule] = [] + for i, rule in enumerate(raw_rules): + if not isinstance(rule, Mapping): + raise ValueError( + f"strain_label_styles[{i}] must be a dict with " + f'"strains" and "style" keys; got {type(rule).__name__}.' + ) + unknown_keys = set(rule) - _RULE_KEYS + if unknown_keys: + raise ValueError( + f"strain_label_styles[{i}] has unknown key(s) " + f"{sorted(unknown_keys)}; allowed keys are " + f"{sorted(_RULE_KEYS)}." + ) + for required in ("strains", "style"): + if required not in rule: + raise ValueError( + f"strain_label_styles[{i}] is missing the required " + f"{required!r} key." + ) + rules.append( + LabelStyleRule( + strains=_resolve_strains( + i, + rule["strains"], + input_tips=input_tips, + drawn_tips=drawn_tips, + tree_strain_field=tree_strain_field, + ), + style=_validate_style(i, rule["style"]), + when=_resolve_when( + i, + rule.get("when"), + declared=declared, + rule_param_names=set(rule_params), + ), + ) + ) + + to_declare = [p for name, p in rule_params.items() if name not in declared] + return rules, to_declare + + +def mark_props( + rules: list[LabelStyleRule], *, default_font_weight: str +) -> tuple[dict[str, alt.ExprRef], dict[str, alt.ExprRef]]: + """Return ``(halo_props, text_props)``: mark properties for the label layers. + + Only properties that some rule sets get an expression; the rest keep the + marks' static settings. Each expression is a chain of ternaries built in + rule order, so the last rule's test is evaluated first and later rules + win where strains overlap. The fallback is the label's unstyled value: + `default_font_weight` for ``fontWeight``, and Vega-Lite's text defaults + (black, fully opaque, upright) for the others. + """ + defaults = { + "fill": "black", + "opacity": 1, + "fontWeight": default_font_weight, + "fontStyle": "normal", + } + text_props: dict[str, alt.ExprRef] = {} + for key in STYLE_KEYS: + expr = json.dumps(defaults[key]) + styled = False + for i, rule in enumerate(rules): + if key not in rule.style: + continue + test = f"datum[{json.dumps(membership_column(i))}]" + if rule.when is not None: + test = f"({rule.when}) && {test}" + expr = f"{test} ? {json.dumps(rule.style[key])} : ({expr})" + styled = True + if styled: + text_props[key] = alt.ExprRef(expr) + halo_props = {k: v for k, v in text_props.items() if k in _GLYPH_KEYS} + return halo_props, text_props + + +def _resolve_strains( + index: int, + strains: Any, + *, + input_tips: set[str], + drawn_tips: set[str], + tree_strain_field: str, +) -> frozenset[str]: + if isinstance(strains, (str, bytes, Mapping)) or not isinstance(strains, Iterable): + raise ValueError( + f'strain_label_styles[{index}]["strains"] must be a list of ' + f"strain names; got {type(strains).__name__} {strains!r}." + ) + strains = list(strains) + if not strains: + raise ValueError( + f'strain_label_styles[{index}]["strains"] is empty; a rule must ' + "name at least one strain." + ) + non_str = [s for s in strains if not isinstance(s, str)] + if non_str: + raise ValueError( + f'strain_label_styles[{index}]["strains"] must contain strings; ' + f"got {non_str[:5]!r}." + ) + unknown = sorted(set(strains) - input_tips) + if unknown: + raise ValueError( + f'strain_label_styles[{index}]["strains"] has {len(unknown)} ' + "strain(s) that are not tips of the tree under " + f"tree_strain_field={tree_strain_field!r}.\n" + f"Sample unknown strains: {unknown[:5]}\n" + f"Sample tree tips: {sorted(input_tips)[:5]}" + ) + return frozenset(s for s in strains if s in drawn_tips) + + +def _validate_style(index: int, style: Any) -> dict[str, Any]: + where = f'strain_label_styles[{index}]["style"]' + if not isinstance(style, Mapping) or not style: + raise ValueError( + f"{where} must be a non-empty dict of label properties (any of " + f"{list(STYLE_KEYS)}); got {style!r}." + ) + for key, value in style.items(): + if key not in STYLE_KEYS: + hint = _STYLE_KEY_HINTS.get(key) + raise ValueError( + f"{where} has unsupported key {key!r}; allowed keys are " + f"{list(STYLE_KEYS)}." + (f" Hint: {hint}." if hint else "") + ) + if key == "fill": + ok = isinstance(value, str) and bool(value.strip()) + expected = "a non-empty color string" + elif key == "opacity": + ok = ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and 0 <= value <= 1 + ) + expected = "a number from 0 to 1" + elif key == "fontWeight": + ok = value in _FONT_WEIGHTS or ( + isinstance(value, int) + and not isinstance(value, bool) + and value in _NUMERIC_FONT_WEIGHTS + ) + expected = f"one of {list(_FONT_WEIGHTS)} or 100-900 in steps of 100" + else: # fontStyle + ok = value in _FONT_STYLES + expected = f"one of {list(_FONT_STYLES)}" + if not ok: + raise ValueError(f"{where}[{key!r}] must be {expected}; got {value!r}.") + return dict(style) + + +def _resolve_when( + index: int, + when: Any, + *, + declared: dict[str, dict], + rule_param_names: set[str], +) -> str | None: + """Return `when` as a Vega expression string (None means always on).""" + where = f'strain_label_styles[{index}]["when"]' + if when is None: + return None + if isinstance(when, alt.Parameter): + if when.param_type != "variable": + raise ValueError( + f"{where} is a selection parameter ({_param_name(when)!r}); " + "only variable parameters (alt.param(...), e.g. bound to a " + "checkbox) are supported, since the label data doesn't carry " + "the fields a selection tests." + ) + return _param_name(when) + if isinstance(when, alt.ParameterExpression): + expr = when.to_dict().get("expr") + elif isinstance(when, Expression): + expr = when.to_dict() + elif isinstance(when, str): + expr = when + else: + raise ValueError( + f"{where} must be a param name or Vega expression string, an " + "alt.Parameter, or an altair parameter expression; got " + f"{type(when).__name__}." + ) + if not isinstance(expr, str) or not expr.strip(): + raise ValueError(f"{where} must not be empty; got {when!r}.") + expr = expr.strip() + if _IDENTIFIER.fullmatch(expr) and expr not in rule_param_names: + if expr not in declared: + raise ValueError( + f"{where}={expr!r} names no parameter declared in the chart. " + f"Declared parameters: {sorted(declared) or 'none'}. Declare " + "it on the chart (e.g. chart.add_params(alt.param(name=" + f"{expr!r}, value=True, bind=alt.binding_checkbox()))) before " + "saving it, or, in Python, pass the alt.Parameter itself." + ) + if "select" in declared[expr]: + raise ValueError( + f"{where}={expr!r} is a selection parameter; only variable " + "parameters (e.g. bound to a checkbox) are supported, since " + "the label data doesn't carry the fields a selection tests." + ) + return expr + + +def _declared_params(spec: Any) -> dict[str, dict]: + """Map every param name declared anywhere in a chart spec to its definition.""" + found: dict[str, dict] = {} + + def walk(node: Any) -> None: + if isinstance(node, dict): + params = node.get("params") + if isinstance(params, list): + for p in params: + if isinstance(p, dict) and isinstance(p.get("name"), str): + found.setdefault(p["name"], p) + for k, v in node.items(): + if k in ("data", "datasets"): + continue + walk(v) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(spec) + return found + + +def _param_name(param: alt.Parameter) -> str: + name = param.name + if hasattr(name, "to_dict"): + name = name.to_dict() + return str(name) diff --git a/src/tree_annotated_plot/_plot.py b/src/tree_annotated_plot/_plot.py index f3639e8..396b857 100644 --- a/src/tree_annotated_plot/_plot.py +++ b/src/tree_annotated_plot/_plot.py @@ -15,7 +15,7 @@ import altair as alt import pandas as pd -from . import _color, _config, _tree +from . import _color, _config, _label_style, _tree from ._config import PlotConfig, TreeLocation # Vega-Lite `orient` values that anchor the legend to the chart's left or @@ -61,6 +61,7 @@ def plot( connect_leader_to_label: bool = False, strain_label_font_size: float = 10.0, strain_label_font_weight: Literal["normal", "bold"] = "normal", + strain_label_styles: list[dict[str, Any]] | None = None, shift_tree_loc: int = 0, color_tree_by: str | None = None, tree_color_scale: dict[str, str] | None = None, @@ -90,6 +91,7 @@ def plot( connect_leader_to_label=connect_leader_to_label, strain_label_font_size=strain_label_font_size, strain_label_font_weight=strain_label_font_weight, + strain_label_styles=strain_label_styles, shift_tree_loc=shift_tree_loc, color_tree_by=color_tree_by, tree_color_scale=tree_color_scale, @@ -124,10 +126,12 @@ def plot( Returns ------- altair.HConcatChart | altair.VConcatChart - For vertical layout (chart strain on `y`), an `HConcatChart` with the - tree on the left and the user's chart on the right. For horizontal - layout (chart strain on `x`), a `VConcatChart` with the tree on top - and the chart below. + For vertical layout (chart strain on `y`), an `HConcatChart` whose + panels are the tree then the chart for `tree_location="left"` (the + default), or the chart then the tree for `"right"`. For horizontal + layout (chart strain on `x`), a `VConcatChart` whose panels are the + chart then the tree for `tree_location="bottom"` (the default), or the + tree then the chart for `"top"`. """ @@ -149,6 +153,14 @@ def _build( set on a config object); `config` carries every styling / behavior knob. Both surfaces converge here so they can never disagree. """ + if config.strain_label_styles and not config.connect_leader_to_label: + raise ValueError( + "strain_label_styles styles the strain labels rendered in the " + "tree panel, which exist only when connect_leader_to_label=True. " + "With it off, the strain labels are your chart's own axis labels; " + "style those with Vega-Lite's conditional axis-label properties " + "(e.g. labelColor / labelFontWeight expressions on datum.value)." + ) root, auspice_meta = _ensure_tree( tree, config.tree_strain_field, @@ -157,6 +169,9 @@ def _build( ) tip_list = _tree.layout(root) tip_names = [t.name for t in tip_list] + # Kept for `strain_label_styles` validation: a styled strain must be a tip + # of the input tree even if `prune_tree_to_chart` later drops it. + input_tip_names = list(tip_names) _check_no_duplicate_tip_strains( tip_names, tree_strain_field=config.tree_strain_field @@ -208,6 +223,14 @@ def _build( tip_list = _tree.layout(root) tip_names = [t.name for t in tip_list] + label_style_rules, label_style_params = _label_style.resolve_rules( + config.strain_label_styles, + input_tip_names=input_tip_names, + drawn_tip_names=tip_names, + chart_spec=spec, + tree_strain_field=config.tree_strain_field, + ) + strain_dim = _coerce_dim( _chart_strain_dim(spec, axis_hits, axis), n_tips=len(tip_names) ) @@ -242,6 +265,7 @@ def _build( connect_leader_to_label=config.connect_leader_to_label, strain_label_font_size=config.strain_label_font_size, strain_label_font_weight=config.strain_label_font_weight, + label_style_rules=label_style_rules, shift_tree_loc=config.shift_tree_loc, tip_names=tip_names, color_mapping=color_mapping, @@ -291,6 +315,15 @@ def _build( _apply_combined_config(combined, hoisted_config) for k, v in hoisted_other.items(): combined._kwds[k] = v + # Declare any `when` parameter the user's chart doesn't already declare. + # Appended to the top-level `params` directly: `combined.add_params(...)` + # pushes params down into each sub-chart instead, which raises on charts + # loaded from a spec (e.g. a `FacetSpec` has no `add_params`). + if label_style_params: + existing = _live_attr(combined, "params") or [] + combined._kwds["params"] = list(existing) + [ + p.param for p in label_style_params + ] return combined @@ -390,9 +423,15 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve A live `alt.TopLevelMixin` is returned as-is — its `$schema` is irrelevant since the constructing altair version is necessarily the running altair version. For dict / JSON path / HTML path inputs we run - the Vega-Lite version check (under `strict_version`), strip altair's - `params[].views` round-trip annotations, and then dispatch to the right - chart subclass via `alt.Chart.from_dict(...)`. + the Vega-Lite version check (under `strict_version`) and then dispatch to + the right chart subclass via `alt.Chart.from_dict(...)`. + + The spec goes to `from_dict` unmodified. In particular, each selection + param's `views` must survive: when `_build` nests a compound chart (layer + / facet / concat), altair keeps its params' existing `views`, and an + emptied list makes Vega-Lite define the selection in every view, which + fails with "Duplicate signal name". `views` is valid on top-level params, + which is the only place altair emits it. """ if isinstance(chart_input, alt.TopLevelMixin): return chart_input @@ -420,8 +459,9 @@ def _load_chart(chart_input: ChartInput, *, strict_version: bool) -> alt.TopLeve ) _check_chart_schema_version(spec, strict_version=strict_version) + # `from_dict` keeps references into the spec (e.g. the `datasets` row + # lists), so copy it to keep the caller's dict out of later mutation. spec = copy.deepcopy(spec) - _strip_params_views(spec) # `from_dict` with validate=True dispatches to the right subclass # (Chart / LayerChart / FacetChart / HConcatChart / VConcatChart / # ConcatChart) based on the spec's shape. @@ -485,26 +525,6 @@ def _check_chart_schema_version(spec: dict, *, strict_version: bool) -> None: ) -def _strip_params_views(spec: Any) -> None: - """Recursively delete `views` from every params[] entry. - - altair's `to_dict()` annotates each selection-param with the views it's - bound to, but altair's own `from_dict()` validation rejects the field. - Stripping is safe: the renderer rebinds params to views on its own. - """ - if isinstance(spec, dict): - params = spec.get("params") - if isinstance(params, list): - for p in params: - if isinstance(p, dict): - p.pop("views", None) - for v in spec.values(): - _strip_params_views(v) - elif isinstance(spec, list): - for item in spec: - _strip_params_views(item) - - class _ScriptCollector(HTMLParser): """Collect the text content of every