tree-annotated-plot 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ """Parse Auspice JSON phylogenetic trees and compute a 2-D layout for drawing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ import pandas as pd
12
+
13
+
14
+ @dataclass
15
+ class TreeNode:
16
+ """A node in a phylogenetic tree.
17
+
18
+ `name` carries the resolved strain identifier for tips (the value at
19
+ `tree_strain_field`). For internal nodes, `name` is the Auspice top-level
20
+ `name` field — internal-node identity isn't used for chart-strain matching.
21
+
22
+ `x` is divergence from the root (read from `node_attrs.div`).
23
+ `y` is set by :func:`layout` and is the integer index of the node's tip
24
+ (for tips) or the midpoint of its descendants' tip indices (for internal
25
+ nodes).
26
+ """
27
+
28
+ name: str
29
+ x: float
30
+ children: list["TreeNode"] = field(default_factory=list)
31
+ y: float | None = None
32
+
33
+ @property
34
+ def is_tip(self) -> bool:
35
+ return not self.children
36
+
37
+
38
+ def load_auspice(
39
+ source: str | Path | dict,
40
+ *,
41
+ tree_strain_field: str,
42
+ branch_length: str = "div",
43
+ strict_version: bool = True,
44
+ ) -> TreeNode:
45
+ """Load an Auspice JSON tree from a path or dict.
46
+
47
+ Parameters
48
+ ----------
49
+ source
50
+ Path to an Auspice JSON file, or an already-parsed dict.
51
+ tree_strain_field
52
+ Where on each tip to find the strain identifier. The literal string
53
+ ``"name"`` selects the tip's top-level ``name`` field; any other value
54
+ ``X`` selects ``node_attrs[X]`` (auto-unwrapping the Auspice
55
+ ``{"value": ...}`` convention). Dotted paths are not accepted.
56
+ branch_length
57
+ Which Auspice node attribute supplies branch lengths. ``"div"``
58
+ (default) reads ``node_attrs.div`` (a scalar absolute divergence
59
+ from the root). ``"num_date"`` reads ``node_attrs.num_date.value``
60
+ (an absolute calendar position in years). In both cases the value
61
+ is stored on each ``TreeNode.x`` and is used as-is by layout,
62
+ segments, and pruning — they're branch-source-agnostic.
63
+ strict_version
64
+ When True (default), raise ``ValueError`` if the Auspice JSON's
65
+ top-level ``version`` field does not start with ``"v2"``. With
66
+ ``False`` the same case becomes a ``warnings.warn``. A missing
67
+ ``version`` field always warns and proceeds.
68
+ """
69
+ _validate_tree_strain_field(tree_strain_field)
70
+ if branch_length not in ("div", "num_date"):
71
+ raise ValueError(
72
+ f"branch_length={branch_length!r} not supported; expected 'div' "
73
+ "or 'num_date'."
74
+ )
75
+
76
+ if isinstance(source, (str, Path)):
77
+ with open(source) as f:
78
+ data = json.load(f)
79
+ elif isinstance(source, dict):
80
+ data = source
81
+ else:
82
+ raise ValueError(f"unsupported tree source type: {type(source).__name__}")
83
+
84
+ _check_auspice_version(data, strict_version=strict_version)
85
+
86
+ if "tree" not in data:
87
+ raise ValueError("Auspice JSON must have a top-level 'tree' field")
88
+ return _parse_node(data["tree"], tree_strain_field, branch_length)
89
+
90
+
91
+ def _check_auspice_version(data: dict, *, strict_version: bool) -> None:
92
+ """Inspect the Auspice top-level `version`; raise / warn on non-v2."""
93
+ version = data.get("version")
94
+ if version is None:
95
+ warnings.warn(
96
+ "Auspice JSON has no top-level 'version' field; proceeding, but "
97
+ "the structure is assumed to be Auspice v2.",
98
+ stacklevel=3,
99
+ )
100
+ return
101
+ if not isinstance(version, str) or not version.startswith("v2"):
102
+ msg = (
103
+ f"Auspice JSON has version={version!r}; this package only "
104
+ "supports v2. Regenerate the tree with a current Augur version."
105
+ )
106
+ if strict_version:
107
+ raise ValueError(msg)
108
+ warnings.warn(msg, stacklevel=3)
109
+
110
+
111
+ def _validate_tree_strain_field(tree_strain_field: str) -> None:
112
+ if not isinstance(tree_strain_field, str) or not tree_strain_field:
113
+ raise ValueError(
114
+ f"tree_strain_field must be a non-empty string, got "
115
+ f"{tree_strain_field!r}"
116
+ )
117
+ if "." in tree_strain_field:
118
+ raise ValueError(
119
+ f"tree_strain_field={tree_strain_field!r} contains a dot. Dotted "
120
+ "paths are not supported. Use 'name' for the top-level Auspice "
121
+ "node `name` field, or a single attribute name (e.g. "
122
+ "'derived_haplotype') to look up under node_attrs (the "
123
+ "{'value': ...} Auspice convention is auto-unwrapped)."
124
+ )
125
+
126
+
127
+ def _resolve_tip_strain(node_dict: dict, tree_strain_field: str) -> str:
128
+ """Look up tree_strain_field on a tip and return its string value."""
129
+ if tree_strain_field == "name":
130
+ v = node_dict.get("name")
131
+ else:
132
+ attrs = node_dict.get("node_attrs", {})
133
+ if tree_strain_field not in attrs:
134
+ raise ValueError(
135
+ f"tree tip {node_dict.get('name', '?')!r} has no "
136
+ f"node_attrs[{tree_strain_field!r}]"
137
+ )
138
+ v = attrs[tree_strain_field]
139
+ if isinstance(v, dict) and "value" in v:
140
+ v = v["value"]
141
+
142
+ if not isinstance(v, str) or not v:
143
+ raise ValueError(
144
+ f"tree_strain_field={tree_strain_field!r} on tip "
145
+ f"{node_dict.get('name', '?')!r} resolved to {v!r}; expected a "
146
+ "non-empty string"
147
+ )
148
+ return v
149
+
150
+
151
+ def _parse_node(d: dict, tree_strain_field: str, branch_length: str) -> TreeNode:
152
+ children_dicts = d.get("children", [])
153
+ branch_value = _resolve_branch_length(d, branch_length)
154
+ if children_dicts:
155
+ # Internal node: use the Auspice top-level name (always present).
156
+ name = d.get("name") or "<internal>"
157
+ else:
158
+ # Tip: resolve via tree_strain_field.
159
+ name = _resolve_tip_strain(d, tree_strain_field)
160
+ children = [
161
+ _parse_node(c, tree_strain_field, branch_length) for c in children_dicts
162
+ ]
163
+ return TreeNode(name=name, x=float(branch_value), children=children)
164
+
165
+
166
+ def _resolve_branch_length(node_dict: dict, branch_length: str) -> float:
167
+ """Pull the branch-length value out of a node's node_attrs."""
168
+ attrs = node_dict.get("node_attrs", {})
169
+ if branch_length == "div":
170
+ v = attrs.get("div")
171
+ if v is None:
172
+ raise ValueError(
173
+ f"tree node {node_dict.get('name', '?')!r} has no "
174
+ "node_attrs.div (required by branch_length='div')."
175
+ )
176
+ return v
177
+ # branch_length == "num_date"
178
+ nd = attrs.get("num_date")
179
+ if not isinstance(nd, dict) or "value" not in nd:
180
+ raise ValueError(
181
+ f"tree node {node_dict.get('name', '?')!r} has no "
182
+ "node_attrs.num_date.value (required by branch_length='num_date')."
183
+ )
184
+ return nd["value"]
185
+
186
+
187
+ def _prune_tree_to(root: TreeNode, keep_strains: set[str]) -> TreeNode:
188
+ """Return a new tree pruned to subtrees whose tips are in `keep_strains`.
189
+
190
+ Strict-flavor pruning per the Phase 2 plan:
191
+ - Drop every tip whose `name` is not in `keep_strains`.
192
+ - Drop every internal node whose entire subtree was dropped.
193
+ - Collapse single-child internals: when an internal node has only
194
+ one surviving child after pruning, the kept child replaces it.
195
+ Because each `TreeNode.x` is the *absolute* divergence from the
196
+ original root (not a delta from its parent), no branch-length
197
+ summation is needed — the kept node's `x` already reflects the
198
+ correct distance from any further-up ancestor. (For `num_date`
199
+ once Phase 2h adds it, the same holds: dates are absolute
200
+ positions.)
201
+ - Re-root to the LCA of the kept tips: if the original root has
202
+ only one kept-child path, the recursive case collapses upward
203
+ until a node with ≥2 surviving subtrees is reached, which becomes
204
+ the new root.
205
+
206
+ Raises ValueError if the resulting tree has no tips (i.e. `keep_strains`
207
+ didn't overlap any tip's name).
208
+ """
209
+ pruned = _prune_recursive(root, keep_strains)
210
+ if pruned is None:
211
+ raise ValueError(
212
+ "after pruning, no tips remain; keep_strains has no overlap "
213
+ "with the tree's tip names"
214
+ )
215
+ return pruned
216
+
217
+
218
+ def _prune_recursive(node: TreeNode, keep_strains: set[str]) -> TreeNode | None:
219
+ """Return a copy of `node` pruned to kept descendants, or None if no
220
+ descendants are kept.
221
+
222
+ Single-child internals are collapsed by returning the surviving child
223
+ directly — its absolute `x` is preserved, so root-to-tip distances
224
+ survive unchanged.
225
+ """
226
+ if node.is_tip:
227
+ if node.name in keep_strains:
228
+ return TreeNode(name=node.name, x=node.x, children=[])
229
+ return None
230
+
231
+ new_children: list[TreeNode] = []
232
+ for c in node.children:
233
+ kept = _prune_recursive(c, keep_strains)
234
+ if kept is not None:
235
+ new_children.append(kept)
236
+
237
+ if not new_children:
238
+ return None
239
+ if len(new_children) == 1:
240
+ # Collapse this single-child internal into its child.
241
+ return new_children[0]
242
+ return TreeNode(name=node.name, x=node.x, children=new_children)
243
+
244
+
245
+ def tips(root: TreeNode) -> Iterator[TreeNode]:
246
+ """Yield tips of the tree in left-to-right (pre-order) traversal."""
247
+ if root.is_tip:
248
+ yield root
249
+ else:
250
+ for c in root.children:
251
+ yield from tips(c)
252
+
253
+
254
+ def layout(root: TreeNode) -> list[TreeNode]:
255
+ """Assign `y` to every node in the tree and return tips in order.
256
+
257
+ Tips get integer y indices 0..N-1 in left-to-right order. Internal nodes
258
+ get the midpoint of their immediate children's y values.
259
+ """
260
+ tip_list = list(tips(root))
261
+ if not tip_list:
262
+ raise ValueError("tree has no tips")
263
+ for i, tip in enumerate(tip_list):
264
+ tip.y = float(i)
265
+ _assign_internal_y(root)
266
+ return tip_list
267
+
268
+
269
+ def _assign_internal_y(node: TreeNode) -> float:
270
+ if node.is_tip:
271
+ assert node.y is not None
272
+ return node.y
273
+ ys = [_assign_internal_y(c) for c in node.children]
274
+ node.y = (min(ys) + max(ys)) / 2.0
275
+ return node.y
276
+
277
+
278
+ def segments(root: TreeNode) -> pd.DataFrame:
279
+ """Build a DataFrame of line segments for drawing the tree.
280
+
281
+ Returns columns `x`, `x2`, `y`, `y2`. Each row is one segment:
282
+
283
+ - For each internal node, one vertical connector from the topmost to the
284
+ bottommost child (x == x2 == node.x).
285
+ - For each non-root node, one horizontal branch from its parent's x to its
286
+ own x at its own y (y == y2).
287
+
288
+ Assumes :func:`layout` has already been called on `root`.
289
+ """
290
+ if root.y is None:
291
+ raise ValueError("call layout(root) before segments(root)")
292
+
293
+ rows: list[dict] = []
294
+
295
+ def walk(node: TreeNode) -> None:
296
+ if node.is_tip:
297
+ return
298
+ child_ys = [c.y for c in node.children]
299
+ rows.append(
300
+ {"x": node.x, "x2": node.x, "y": min(child_ys), "y2": max(child_ys)}
301
+ )
302
+ for c in node.children:
303
+ rows.append({"x": node.x, "x2": c.x, "y": c.y, "y2": c.y})
304
+ walk(c)
305
+
306
+ walk(root)
307
+ return pd.DataFrame(rows, columns=["x", "x2", "y", "y2"])
@@ -0,0 +1,165 @@
1
+ """`tree-annotated-plot` command-line entry point.
2
+
3
+ The CLI's --tree, --chart, --output options are hand-written (they are
4
+ CLI-specific data inputs without a config-object equivalent: their
5
+ *types* differ between Python and CLI surfaces, even though their
6
+ *descriptions* are single-sourced as `_config.{TREE,CHART,OUTPUT}_DESCRIPTION`).
7
+ Every other option is **auto-generated** from `PlotConfig` so its
8
+ `--help` text comes from the same `Annotated[T, "<description>"]`
9
+ metadata that documents the Python `tree_annotated_plot.plot` function. Adding a new
10
+ parameter takes one edit to `_config.py`; both surfaces pick it up.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import types
17
+ import typing
18
+ from pathlib import Path
19
+ from typing import Any, Literal, get_args, get_origin, get_type_hints
20
+
21
+ import click
22
+
23
+ from ._config import (
24
+ CHART_DESCRIPTION,
25
+ OUTPUT_DESCRIPTION,
26
+ TREE_DESCRIPTION,
27
+ PlotConfig,
28
+ )
29
+ from ._plot import _build
30
+
31
+ _SCALAR_CLICK_TYPE = {int: click.INT, float: click.FLOAT, str: click.STRING}
32
+
33
+
34
+ def _option_for_field(field: dataclasses.Field, hints: dict) -> Any:
35
+ """Build a `click.option(...)` decorator from a PlotConfig field.
36
+
37
+ Handles every field shape we use:
38
+ - bool (with default) → `--flag/--no-flag` dual form.
39
+ - Literal[...] → `click.Choice([...])`, required if no default.
40
+ - X | None → unwraps to X with default None. Literal | None becomes
41
+ a Choice with default None.
42
+ - int / float / str → click.INT / click.FLOAT / click.STRING.
43
+
44
+ Required fields (no default) become `required=True`.
45
+ """
46
+ annotated = hints[field.name]
47
+ real_type = get_args(annotated)[0]
48
+ description = annotated.__metadata__[0]
49
+
50
+ cli_name = "--" + field.name.replace("_", "-")
51
+ has_default = field.default is not dataclasses.MISSING
52
+
53
+ # Bool → dual flag.
54
+ if real_type is bool:
55
+ return click.option(
56
+ f"{cli_name}/--no-{field.name.replace('_', '-')}",
57
+ default=field.default,
58
+ help=description,
59
+ show_default=True,
60
+ )
61
+
62
+ # Literal → Choice.
63
+ if get_origin(real_type) is Literal:
64
+ choices = [str(c) for c in get_args(real_type)]
65
+ kwargs: dict[str, Any] = {
66
+ "type": click.Choice(choices),
67
+ "help": description,
68
+ "show_default": has_default,
69
+ }
70
+ if has_default:
71
+ kwargs["default"] = field.default
72
+ else:
73
+ kwargs["required"] = True
74
+ return click.option(cli_name, **kwargs)
75
+
76
+ # Optional / Union — unwrap to the non-None branch.
77
+ origin = get_origin(real_type)
78
+ if origin in (typing.Union, types.UnionType):
79
+ non_none = [a for a in get_args(real_type) if a is not type(None)]
80
+ if len(non_none) == 1:
81
+ inner = non_none[0]
82
+ if get_origin(inner) is Literal:
83
+ choices = [str(c) for c in get_args(inner)]
84
+ return click.option(
85
+ cli_name,
86
+ type=click.Choice(choices),
87
+ default=field.default if has_default else None,
88
+ help=description,
89
+ show_default=True,
90
+ )
91
+ return click.option(
92
+ cli_name,
93
+ type=_SCALAR_CLICK_TYPE.get(inner, click.STRING),
94
+ default=field.default if has_default else None,
95
+ help=description,
96
+ show_default=True,
97
+ )
98
+
99
+ # Plain scalar.
100
+ kwargs = {
101
+ "type": _SCALAR_CLICK_TYPE.get(real_type, click.STRING),
102
+ "help": description,
103
+ "show_default": has_default,
104
+ }
105
+ if has_default:
106
+ kwargs["default"] = field.default
107
+ else:
108
+ kwargs["required"] = True
109
+ return click.option(cli_name, **kwargs)
110
+
111
+
112
+ def _stack_config_options(command: Any) -> Any:
113
+ """Apply one `click.option(...)` per PlotConfig field to `command`.
114
+
115
+ `click.option(...)` invoked on an already-built `Command` *appends*
116
+ to `command.params`, so iterating fields in declaration order gives
117
+ --help in declaration order (required fields first, since
118
+ `chart_strain_field` and `tree_strain_field` lead PlotConfig).
119
+ """
120
+ hints = get_type_hints(PlotConfig, include_extras=True)
121
+ for field in dataclasses.fields(PlotConfig):
122
+ command = _option_for_field(field, hints)(command)
123
+ return command
124
+
125
+
126
+ @_stack_config_options
127
+ @click.command(
128
+ name="tree-annotated-plot",
129
+ help=(
130
+ "Plot a phylogenetic tree alongside a Vega-Lite chart whose "
131
+ "categorical axis is reordered to match the tree's tip order. "
132
+ "Save the result as HTML / JSON / PNG / SVG / PDF "
133
+ "(format is dispatched on --output's extension)."
134
+ ),
135
+ )
136
+ @click.option(
137
+ "--tree",
138
+ "tree_path",
139
+ required=True,
140
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
141
+ help=TREE_DESCRIPTION,
142
+ )
143
+ @click.option(
144
+ "--chart",
145
+ "chart_path",
146
+ required=True,
147
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
148
+ help=CHART_DESCRIPTION,
149
+ )
150
+ @click.option(
151
+ "--output",
152
+ "output_path",
153
+ required=True,
154
+ type=click.Path(dir_okay=False, path_type=Path),
155
+ help=OUTPUT_DESCRIPTION,
156
+ )
157
+ def main(tree_path: Path, chart_path: Path, output_path: Path, **kwargs: Any) -> None:
158
+ """Top-level CLI entry point."""
159
+ config = PlotConfig(**kwargs)
160
+ out = _build(tree_path, chart_path, config)
161
+ out.save(str(output_path))
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: tree-annotated-plot
3
+ Version: 0.0.1
4
+ Summary: Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree.
5
+ Project-URL: Documentation, https://jbloomlab.github.io/tree-annotated-plot/
6
+ Project-URL: Homepage, https://github.com/jbloomlab/tree-annotated-plot
7
+ Project-URL: Repository, https://github.com/jbloomlab/tree-annotated-plot
8
+ Project-URL: Issues, https://github.com/jbloomlab/tree-annotated-plot/issues
9
+ Author-email: Jesse Bloom <jbloom@fredhutch.org>
10
+ License: MIT License
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Operating System :: OS Independent
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
35
+ Classifier: Topic :: Scientific/Engineering :: Visualization
36
+ Requires-Python: >=3.13
37
+ Requires-Dist: altair<7,>=6.0
38
+ Requires-Dist: click>=8.0
39
+ Requires-Dist: pandas>=2.0
40
+ Requires-Dist: vl-convert-python>=1.9
41
+ Provides-Extra: dev
42
+ Requires-Dist: black>=24; extra == 'dev'
43
+ Requires-Dist: pytest>=8; extra == 'dev'
44
+ Requires-Dist: ruff>=0.6; extra == 'dev'
45
+ Provides-Extra: docs
46
+ Requires-Dist: mkdocs-click>=0.8; extra == 'docs'
47
+ Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
48
+ Requires-Dist: mkdocs>=1.6; extra == 'docs'
49
+ Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
50
+ Description-Content-Type: text/markdown
51
+
52
+ # `tree-annotated-plot`: annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree
53
+
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
55
+ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
56
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
57
+
58
+ This is a Python package from the [Bloom lab](https://jbloomlab.org/) that allows you to combine an Altair / Vega-Lite plot with a Nextstrain JSON of a phylogenetic tree so that the tree is aligned to annotate strains on the axis of the plot.
59
+ See [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.io/tree-annotated-plot/) for detailed documentation.
60
+
61
+ > **Note:** charts must be saved from altair 6+ (Vega-Lite v6). Older specs raise by default; pass `--no-strict-version` (CLI) or `strict_version=False` (Python) to override at your own risk. See the [docs](https://jbloomlab.github.io/tree-annotated-plot/) for details.
62
+
63
+ ## Notes for developing the package
64
+
65
+ ### Installation (development)
66
+
67
+ ```bash
68
+ python3 -m venv .venv
69
+ source .venv/bin/activate
70
+ pip install -e ".[dev,docs]"
71
+ ```
72
+
73
+ [pyproject.toml](pyproject.toml) is the canonical source for the supported Python
74
+ version and runtime dependencies.
75
+
76
+ ### Visual verification after code changes
77
+
78
+ This package has automated tests for chart **structure** (encodings,
79
+ sort order, tip reconciliation, scale-bar arithmetic, etc.), but
80
+ **not** for chart **appearance** — there are no image-snapshot
81
+ regression tests, because rendering is sensitive to fonts,
82
+ `vl-convert-python` versions, and platform differences in ways that
83
+ make pixel-level comparison flaky in practice.
84
+
85
+ So the discipline is manual: any time you change code that affects
86
+ rendering (`src/tree_annotated_plot/_plot.py`,
87
+ `src/tree_annotated_plot/_tree.py`, the example scripts under
88
+ `examples/`, or anything they touch), run the full check below and
89
+ **eyeball every example** before considering the change done.
90
+
91
+ ```bash
92
+ bash scripts/check.sh # lint + format + pytest (all 100+ tests)
93
+ bash scripts/build_docs.sh # regenerates docs/images + docs/charts and runs mkdocs --strict
94
+ ```
95
+
96
+ Then open `site/examples.html` in a browser and, for each example:
97
+
98
+ 1. Look at the embedded SVG screenshot. Does it match what the
99
+ feature you changed should produce? Are tip rows still aligned with
100
+ the chart rows? Is the tree's tip-end facing the chart? Is the
101
+ scale bar (if enabled) centered and labeled correctly?
102
+ 2. Click "Open the interactive chart →". Hover tips to confirm
103
+ tooltips fire; if there's a cohort selector (Kikawa examples),
104
+ toggle it; pan/zoom if relevant.
105
+ 3. Compare against the
106
+ [deployed site](https://jbloomlab.github.io/tree-annotated-plot/)
107
+ (which still reflects `main` before your change) for what "good"
108
+ looks like.
109
+
110
+ If something visibly regresses, fix it before pushing — there's no
111
+ CI gate that will catch a visual regression for you.
112
+
113
+ ### Continuous integration
114
+
115
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every
116
+ push to `main` and every pull request: it installs the package, fetches
117
+ the upstream Kikawa Auspice JSONs (so the real-data tests actually
118
+ exercise rather than skip), builds the Kikawa titer chart specs, and
119
+ runs `ruff check`, `black --check`, and `pytest tests/`. This mirrors
120
+ [`scripts/check.sh`](scripts/check.sh) — green locally should mean
121
+ green in CI.
122
+
123
+ A separate workflow,
124
+ [`.github/workflows/docs.yml`](.github/workflows/docs.yml), builds the
125
+ docs site on every push to `main` and every PR (catching broken
126
+ mkdocstrings refs / missing images / unresolved cross-links before
127
+ merge), and additionally deploys `site/` to GitHub Pages on push to
128
+ `main`.
129
+
130
+ A third workflow,
131
+ [`.github/workflows/release.yml`](.github/workflows/release.yml),
132
+ builds the sdist + wheel and publishes them to PyPI when a `v*` tag
133
+ is pushed (see "Releasing a new version" below).
134
+
135
+ ### Releasing a new version
136
+
137
+ Releases are fully automated by tag push. Trusted publishing (OIDC)
138
+ means **no API token or password is stored in the repo** — PyPI
139
+ authenticates the workflow via its repo + workflow filename +
140
+ environment.
141
+
142
+ One-time setup (only needed before the first release):
143
+
144
+ 1. Reserve `tree-annotated-plot` on [PyPI](https://pypi.org/) (first
145
+ successful publish creates it; or claim it manually via
146
+ `pip install build && python -m build && twine upload dist/*` from a
147
+ maintainer machine).
148
+ 2. On PyPI, go to the project → "Publishing" tab → "Add a new pending
149
+ publisher". Fill in: owner `jbloomlab`, repository
150
+ `tree-annotated-plot`, workflow filename `release.yml`, environment
151
+ `pypi`.
152
+ 3. (Optional but recommended) On GitHub, repo Settings → Environments
153
+ → New environment → name `pypi`. Add yourself as a required
154
+ reviewer if you want a manual approval gate before each release
155
+ lands on PyPI.
156
+
157
+ Per-release recipe (every time):
158
+
159
+ ```bash
160
+ # 1. Edit pyproject.toml and bump `version = "X.Y.Z"`.
161
+ # 2. Commit and tag (the workflow verifies they match before publishing).
162
+ git commit -am "release vX.Y.Z"
163
+ git tag vX.Y.Z
164
+ git push && git push --tags
165
+ ```
166
+
167
+ The `release` workflow then builds, verifies tag ⇋ version match, and
168
+ publishes to PyPI.
169
+
170
+ ### Documentation
171
+ The docs are at
172
+ [https://jbloomlab.github.io/tree-annotated-plot/](https://jbloomlab.github.io/tree-annotated-plot/).
173
+ They are auto-deployed by GitHub Actions on every push to `main` —
174
+ [`.github/workflows/docs.yml`](.github/workflows/docs.yml) installs
175
+ the package + docs extras, runs
176
+ [`scripts/generate_docs_assets.py`](scripts/generate_docs_assets.py)
177
+ to render images and standalone interactive HTML for each
178
+ example, then `mkdocs build --strict`, and uploads `site/` as a
179
+ GitHub Pages artifact.
180
+
181
+ ### Building documentation locally
182
+
183
+ For day-to-day editing of the docs, use MkDocs's live-reload server
184
+ (saves go straight to your browser):
185
+
186
+ ```bash
187
+ mkdocs serve # http://localhost:8000
188
+ ```
189
+
190
+ To do this on the Fred Hutch remote server, do:
191
+ ```bash
192
+ mkdocs serve -a $(hostname -i | awk '{print $1}'):$(fhfreeport)
193
+ ```
194
+
195
+ For the strict build that matches CI (catches broken
196
+ [`mkdocstrings`](https://mkdocstrings.github.io/) refs, missing
197
+ images, unresolved cross-links):
198
+
199
+ ```bash
200
+ bash scripts/build_docs.sh
201
+ ```
202
+
203
+ This first runs `scripts/generate_docs_assets.py` (so the example
204
+ SVGs and interactive HTMLs exist before MkDocs reads `docs/`), then
205
+ `mkdocs build --strict`. Output lands in `site/`.
206
+
207
+ ### Adding a new example
208
+
209
+ The docs follow a single template per example: a short motivation,
210
+ an embedded SVG screenshot, a link to the fully-interactive
211
+ standalone HTML, and the commands to reproduce (CLI form first,
212
+ Python API form second). [`docs/examples.md`](docs/examples.md)
213
+ shows the existing shape. To add another:
214
+
215
+ 1. Drop a runnable script into `examples/`. Keep helpers at module
216
+ level (callable from outside) so the asset script can import them.
217
+ 2. Add a clause to `scripts/generate_docs_assets.py` that imports
218
+ the script and calls `tree_annotated_plot.plot(...)` followed by saving both
219
+ `<name>.svg` (into `docs/images/`) and `<name>.html` (into
220
+ `docs/charts/`). Both directories are gitignored — only the
221
+ asset script writes there.
222
+ 3. Add a section to `docs/examples.md` matching the template.
223
+ 4. Run `bash scripts/build_docs.sh` and open `site/examples.html`
224
+ to verify. Push when happy; the deployed site updates on the
225
+ next CI run.