Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<selection>_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

Expand Down
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
71 changes: 71 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions examples/flu-seqneut-2025to2026_titer_charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions scripts/generate_docs_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 41 additions & 4 deletions src/tree_annotated_plot/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading