Skip to content

Commit b32302e

Browse files
jbloomclaude
andcommitted
inset strain labels from the chart edge and add a spacing parameter
With `connect_leader_to_label` on, the strain labels were anchored exactly at `chart_edge_branch` — the tree panel's chart-facing edge — and right-aligned, so the text ran up against the chart's frame and the frame line stopped reading as a line. `label_pad_px` was already counted into the label strip's width, but because the anchor sat on the edge that pad landed on the tree side of the text rather than between the text and the chart. Reserve a pad at each end of the strip and anchor the labels one pad inside the chart-facing edge, with the leader lines stopping at the same place so they meet the text instead of passing under it to the panel edge. The tree panel grows by one pad, so a hand-tuned `shift_tree_loc` may want the same adjustment. Separately, `_concat_for_location` hardcoded `spacing=0` at all four `tree_location` branches, leaving callers no way to open a gap between the panels. Expose it as `spacing`, defaulting to 0 so existing charts are unchanged. It is easy to confuse with `shift_tree_loc`, which moves the tree within its own panel by resizing the label strip, so the two descriptions now cross-reference each other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8ec3375 commit b32302e

7 files changed

Lines changed: 183 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `spacing` (default `0`): pixels of blank space between the tree panel and
13+
the chart panel. `0` keeps the two butted together, as before. Distinct from
14+
`shift_tree_loc`, which moves the tree within its own panel by resizing the
15+
label strip. CLI form: `--spacing`.
16+
17+
### Fixed
18+
19+
- With `connect_leader_to_label` on, strain labels were anchored exactly on the
20+
tree panel's chart-facing edge, so the text ran up against the chart's frame
21+
and the frame line became hard to read. The pad that the label strip already
22+
reserved is now applied between the text and the chart, and the leader lines
23+
stop where the text begins rather than continuing to the panel edge. The tree
24+
panel grows by one pad (`max(4, strain_label_font_size * 0.4)` px), so a
25+
hand-tuned `shift_tree_loc` may want the same adjustment.
26+
1027
## [0.3.0] - 2026-05-19
1128

1229
### Added

‎src/tree_annotated_plot/_config.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ class PlotConfig:
6868
"(x-encoded).",
6969
] = None
7070

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+
7180
tree_line_width: Annotated[
7281
float,
7382
"Stroke width (px) for the tree's branch lines. Default 2.",
@@ -155,8 +164,9 @@ class PlotConfig:
155164
shift_tree_loc: Annotated[
156165
int,
157166
"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.",
167+
"(negative) the chart, by resizing the label strip inside the tree "
168+
"panel. Default 0. Has no effect when connect_leader_to_label is off; "
169+
"to set the gap between the tree and chart panels use `spacing`.",
160170
] = 0
161171

162172
color_tree_by: Annotated[

‎src/tree_annotated_plot/_plot.py‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def plot(
4949
branch_length: Literal["div", "num_date"],
5050
tree_size: int = 100,
5151
tree_location: TreeLocation | None = None,
52+
spacing: int = 0,
5253
tree_line_width: float = 2.0,
5354
tree_node_size: float = 45,
5455
leader_line_width: float = 1.0,
@@ -77,6 +78,7 @@ def plot(
7778
branch_length=branch_length,
7879
tree_size=tree_size,
7980
tree_location=tree_location,
81+
spacing=spacing,
8082
tree_line_width=tree_line_width,
8183
tree_node_size=tree_node_size,
8284
leader_line_width=leader_line_width,
@@ -281,7 +283,10 @@ def _build(
281283
hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
282284

283285
combined = _concat_for_location(
284-
tree_chart=tree_chart, user_chart=new_chart, location=location
286+
tree_chart=tree_chart,
287+
user_chart=new_chart,
288+
location=location,
289+
spacing=config.spacing,
285290
)
286291
_apply_combined_config(combined, hoisted_config)
287292
for k, v in hoisted_other.items():
@@ -320,6 +325,7 @@ def _concat_for_location(
320325
tree_chart: alt.TopLevelMixin,
321326
user_chart: alt.TopLevelMixin,
322327
location: TreeLocation,
328+
spacing: int,
323329
) -> alt.HConcatChart | alt.VConcatChart:
324330
"""Concat tree and chart in the order implied by the tree's location.
325331
@@ -333,19 +339,19 @@ def _concat_for_location(
333339
user-chart marks whose color values aren't in the tree's domain.
334340
"""
335341
if location == "left":
336-
return alt.hconcat(tree_chart, user_chart, spacing=0).resolve_scale(
342+
return alt.hconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
337343
y="independent", color="independent"
338344
)
339345
if location == "right":
340-
return alt.hconcat(user_chart, tree_chart, spacing=0).resolve_scale(
346+
return alt.hconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
341347
y="independent", color="independent"
342348
)
343349
if location == "top":
344-
return alt.vconcat(tree_chart, user_chart, spacing=0).resolve_scale(
350+
return alt.vconcat(tree_chart, user_chart, spacing=spacing).resolve_scale(
345351
x="independent", color="independent"
346352
)
347353
if location == "bottom":
348-
return alt.vconcat(user_chart, tree_chart, spacing=0).resolve_scale(
354+
return alt.vconcat(user_chart, tree_chart, spacing=spacing).resolve_scale(
349355
x="independent", color="independent"
350356
)
351357
raise ValueError(f"unreachable: tree_location={location!r}")
@@ -1615,8 +1621,9 @@ def _build_tree_chart(
16151621
char_px = strain_label_font_size * _LABEL_CHAR_PX_RATIO
16161622
label_pad_px = max(_LABEL_PAD_PX_MIN, strain_label_font_size * _LABEL_PAD_RATIO)
16171623
# 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
1624+
# extension on the leader-facing side, plus a pad at each end: one
1625+
# between the tree and the text, one between the text and the chart.
1626+
label_pixel_width = 2 * label_pad_px + max_name_len * char_px + halo_px / 2
16201627
strip_pixel_width = label_pixel_width - shift_tree_loc
16211628
if strip_pixel_width <= 0:
16221629
raise ValueError(
@@ -1629,9 +1636,13 @@ def _build_tree_chart(
16291636
per_pixel = branch_span / tree_size if tree_size else 0.0
16301637
extra_branch_units = strip_pixel_width * per_pixel
16311638
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
1639+
# Anchor the labels one pad *inside* the chart-facing edge rather than
1640+
# on it, so the text does not run up against the chart's frame. The
1641+
# leaders stop at the same place, where the text begins.
1642+
label_anchor_branch = chart_edge_branch - label_pad_px * per_pixel
1643+
tips_df = tips_df.assign(x_label=label_anchor_branch)
1644+
leader_df = tips_df[tips_df["x"] < label_anchor_branch].assign(
1645+
x2=label_anchor_branch
16351646
)
16361647
extended_tree_size = tree_size + strip_pixel_width
16371648
else:
@@ -1680,9 +1691,9 @@ def _build_tree_chart(
16801691
# When connect_leader_to_label is on, the branch domain is extended
16811692
# past `branch_max` to `chart_edge_branch` so the label strip has
16821693
# 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).
1694+
# `tree_size` on the panel. Each label's chart-facing edge sits one
1695+
# `label_pad_px` inside `chart_edge_branch`, aligned outward (right
1696+
# for tree on the left, left for tree on the right).
16861697
if tree_location == "left":
16871698
branch_domain = [branch_min, chart_edge_branch]
16881699
text_align = "right"
@@ -1783,7 +1794,8 @@ def _build_tree_chart(
17831794
# strip at bottom, opposite the chart above).
17841795
# tree_location="bottom" → root at bottom → branch_max at top (label
17851796
# strip at top, opposite the chart below).
1786-
# Each label's chart-facing edge is anchored at `chart_edge_branch`.
1797+
# Each label's chart-facing edge sits one `label_pad_px` inside
1798+
# `chart_edge_branch`.
17871799
# The text mark is rotated 270° (reads bottom-to-top), which maps
17881800
# pre-rotation `align="right"` to a top anchor (text extends down)
17891801
# and `align="left"` to a bottom anchor (text extends up). For tree

‎tests/test_appearance_tuning.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,3 +795,30 @@ def collect_text_marks(o: Any, hits: list[dict]) -> None:
795795
collect_text_marks(tree_panel, text_marks)
796796
sizes = [m.get("fontSize") for m in text_marks if "fontSize" in m]
797797
assert 16.0 in sizes or 16 in sizes
798+
799+
800+
def test_cli_spacing(tmp_path):
801+
"""`--spacing` reaches the saved spec's top-level concat gap."""
802+
tree_path, chart_path, out_path = _cli_setup(
803+
tmp_path, _attr_auspice(), _vertical_chart(["A", "B", "C", "D"])
804+
)
805+
_run_cli(
806+
[
807+
"--tree",
808+
str(tree_path),
809+
"--chart",
810+
str(chart_path),
811+
"--output",
812+
str(out_path),
813+
"--chart-strain-field",
814+
"strain",
815+
"--tree-strain-field",
816+
"name",
817+
"--branch-length",
818+
"div",
819+
"--spacing",
820+
"9",
821+
]
822+
)
823+
spec = json.loads(out_path.read_text())
824+
assert spec["spacing"] == 9

‎tests/test_cli.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def test_help_lists_auto_generated_options() -> None:
4545
"--branch-length",
4646
"--tree-size",
4747
"--tree-location",
48+
"--spacing",
4849
"--tree-line-width",
4950
"--scale-bar / --no-scale-bar",
5051
"--strict-version / --no-strict-version",

‎tests/test_label_connection.py‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,9 @@ def test_default_leader_endpoint_stops_at_branch_max() -> None:
285285

286286
def test_vertical_left_renders_text_with_right_align() -> None:
287287
"""Vertical layout with `connect_leader_to_label=True` and default
288-
`tree_location="left"`: tree on left, chart on right; labels flush
289-
against the chart on the panel's right (chart-facing) edge. Text
290-
mark uses `align="right"` and `x_label > branch_max`."""
288+
`tree_location="left"`: tree on left, chart on right; labels sit just
289+
inside the panel's right (chart-facing) edge. Text mark uses
290+
`align="right"` and `x_label > branch_max`."""
291291
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
292292
text = _text_layer(out)
293293
assert text is not None
@@ -557,3 +557,48 @@ def test_default_off_keeps_user_axis_labels_intact() -> None:
557557
assert axis.get("labelFontWeight") == "bold"
558558
assert axis.get("labels") is not False
559559
assert axis.get("ticks") is not False
560+
561+
562+
# ---------- label inset from the chart-facing edge ----------
563+
564+
565+
def test_labels_sit_inside_the_chart_facing_edge() -> None:
566+
"""Labels are anchored one pad *inside* `chart_edge_branch` rather than on
567+
it, so the glyphs do not run up against the chart panel's frame. The panel
568+
itself still extends to `chart_edge_branch`, which is the chart-facing end
569+
of the branch scale's domain."""
570+
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
571+
text = _text_layer(out)
572+
assert text is not None
573+
# tree on the left → branch domain is [branch_min, chart_edge_branch]
574+
chart_edge_branch = text["encoding"]["x"]["scale"]["domain"][1]
575+
rows = _resolve_dataset(out.to_dict(), text)
576+
assert rows
577+
for row in rows:
578+
assert row["x_label"] < chart_edge_branch
579+
580+
581+
def test_labels_sit_inside_the_chart_facing_edge_horizontal() -> None:
582+
"""Same inset in horizontal layout, where the default tree_location is
583+
"bottom" and the branch domain runs [branch_min, chart_edge_branch] on y."""
584+
out = tree_annotated_plot.plot(_auspice(), _horizontal_chart(), **_on_kw())
585+
text = _text_layer(out)
586+
assert text is not None
587+
chart_edge_branch = text["encoding"]["y"]["scale"]["domain"][1]
588+
rows = _resolve_dataset(out.to_dict(), text)
589+
assert rows
590+
for row in rows:
591+
assert row["x_label"] < chart_edge_branch
592+
593+
594+
def test_leaders_stop_where_the_labels_begin() -> None:
595+
"""The leaders run to the label anchor rather than on to the panel edge, so
596+
the dashed line meets the text instead of passing under it to the frame."""
597+
out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
598+
text = _text_layer(out)
599+
assert text is not None
600+
anchors = {row["x_label"] for row in _resolve_dataset(out.to_dict(), text)}
601+
assert len(anchors) == 1, f"expected one shared label anchor, got {anchors}"
602+
(anchor,) = anchors
603+
for row in _resolve_dataset(out.to_dict(), _leader_layer(out)):
604+
assert row["x2"] == pytest.approx(anchor)

‎tests/test_tree_location.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,55 @@ def test_right_with_x_encoded_strain_raises() -> None:
181181
tree_annotated_plot.plot(
182182
_auspice_4_tip(), _horizontal_chart(), tree_location="right", **_kw()
183183
)
184+
185+
186+
# ---------- spacing between the tree and chart panels ----------
187+
188+
189+
@pytest.mark.parametrize(
190+
("chart_fn", "location"),
191+
[
192+
(_vertical_chart, "left"),
193+
(_vertical_chart, "right"),
194+
(_horizontal_chart, "top"),
195+
(_horizontal_chart, "bottom"),
196+
],
197+
)
198+
def test_spacing_defaults_to_zero(chart_fn, location) -> None:
199+
"""The default butts the two panels together, as before `spacing` existed."""
200+
out = tree_annotated_plot.plot(
201+
_auspice_4_tip(), chart_fn(), tree_location=location, **_kw()
202+
)
203+
assert out.to_dict()["spacing"] == 0
204+
205+
206+
@pytest.mark.parametrize(
207+
("chart_fn", "location"),
208+
[
209+
(_vertical_chart, "left"),
210+
(_vertical_chart, "right"),
211+
(_horizontal_chart, "top"),
212+
(_horizontal_chart, "bottom"),
213+
],
214+
)
215+
def test_spacing_propagates_to_the_concat(chart_fn, location) -> None:
216+
"""An explicit `spacing` reaches the top-level concat spec, for every
217+
`tree_location` — the gap is between the two panels, so it applies
218+
uniformly to the hconcat and vconcat forms alike."""
219+
out = tree_annotated_plot.plot(
220+
_auspice_4_tip(), chart_fn(), tree_location=location, spacing=7, **_kw()
221+
)
222+
assert out.to_dict()["spacing"] == 7
223+
224+
225+
def test_spacing_does_not_disturb_panel_sizes() -> None:
226+
"""Spacing is applied along the branch axis, between panels, so neither
227+
panel's own dimensions change and strain-row alignment is untouched."""
228+
out_zero = tree_annotated_plot.plot(_auspice_4_tip(), _vertical_chart(), **_kw())
229+
out_gap = tree_annotated_plot.plot(
230+
_auspice_4_tip(), _vertical_chart(), spacing=12, **_kw()
231+
)
232+
d_zero, d_gap = out_zero.to_dict(), out_gap.to_dict()
233+
for i in (0, 1):
234+
assert d_zero["hconcat"][i]["width"] == d_gap["hconcat"][i]["width"]
235+
assert d_zero["hconcat"][i].get("height") == d_gap["hconcat"][i].get("height")

0 commit comments

Comments
 (0)