tree-annotated-plot 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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