tree-annotated-plot 0.3.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (43) hide show
  1. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/CHANGELOG.md +27 -0
  2. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/PKG-INFO +3 -3
  3. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/pyproject.toml +9 -2
  4. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_config.py +12 -2
  5. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_plot.py +36 -14
  6. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_appearance_tuning.py +27 -0
  7. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_cli.py +1 -0
  8. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_label_connection.py +48 -3
  9. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_tree_location.py +52 -0
  10. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/.github/workflows/docs.yml +0 -0
  11. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/.github/workflows/release.yml +0 -0
  12. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/.github/workflows/tests.yml +0 -0
  13. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/.gitignore +0 -0
  14. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/CLAUDE.md +0 -0
  15. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/LICENSE +0 -0
  16. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/README.md +0 -0
  17. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/docs/cli.md +0 -0
  18. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/docs/examples.md +0 -0
  19. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/docs/index.md +0 -0
  20. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/docs/python-api.md +0 -0
  21. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/examples/fetch_auspice_data.py +0 -0
  22. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/examples/flu-seqneut-2025to2026_titer_charts.py +0 -0
  23. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/examples/synthetic_example.py +0 -0
  24. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/mkdocs.yml +0 -0
  25. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/scripts/build_docs.sh +0 -0
  26. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/scripts/check.sh +0 -0
  27. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/scripts/generate_docs_assets.py +0 -0
  28. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/__init__.py +0 -0
  29. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_color.py +0 -0
  30. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/_tree.py +0 -0
  31. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/src/tree_annotated_plot/cli.py +0 -0
  32. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_branch_length.py +0 -0
  33. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_chart_loader.py +0 -0
  34. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_color_tree.py +0 -0
  35. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_examples_run.py +0 -0
  36. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_introspection.py +0 -0
  37. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_line_order.py +0 -0
  38. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_plot.py +0 -0
  39. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_plot_config.py +0 -0
  40. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_real_data.py +0 -0
  41. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_reconciliation.py +0 -0
  42. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_scale_bar.py +0 -0
  43. {tree_annotated_plot-0.3.0 → tree_annotated_plot-0.4.0}/tests/test_style.py +0 -0
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-08-20
11
+
12
+ ### Added
13
+
14
+ - `spacing` (default `0`): pixels of blank space between the tree panel and
15
+ the chart panel. `0` keeps the two butted together, as before. Distinct from
16
+ `shift_tree_loc`, which moves the tree within its own panel by resizing the
17
+ label strip. CLI form: `--spacing`.
18
+
19
+ ### Changed
20
+
21
+ - Pin `ruff` to `>=0.16,<0.17` and state the lint rules explicitly with
22
+ `[tool.ruff.lint] select`. Ruff's *default* rule set widened in 0.16, which
23
+ turned CI red on unchanged code; the selection is now a project decision
24
+ rather than whatever the installed ruff defaults to. Development-only, no
25
+ effect on the package.
26
+
27
+ ### Fixed
28
+
29
+ - With `connect_leader_to_label` on, strain labels were anchored exactly on the
30
+ tree panel's chart-facing edge, so the text ran up against the chart's frame
31
+ and the frame line became hard to read. The pad that the label strip already
32
+ reserved is now applied between the text and the chart, and the leader lines
33
+ stop where the text begins rather than continuing to the panel edge. The gap
34
+ is `max(3, strain_label_font_size * 0.2)` px, and the tree panel grows by that
35
+ much, so a hand-tuned `shift_tree_loc` may want the same adjustment.
36
+
10
37
  ## [0.3.0] - 2026-05-19
11
38
 
12
39
  ### Added
@@ -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.4.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'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "tree-annotated-plot"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Annotate the axis of an Altair / Vega-Lite plot with a phylogenetic tree."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -36,7 +36,7 @@ Issues = "https://github.com/jbloomlab/tree-annotated-plot/issues"
36
36
  [project.optional-dependencies]
37
37
  dev = [
38
38
  "pytest>=8",
39
- "ruff>=0.6",
39
+ "ruff>=0.16,<0.17",
40
40
  "black>=24",
41
41
  ]
42
42
  docs = [
@@ -58,3 +58,10 @@ target-version = ["py311"]
58
58
 
59
59
  [tool.ruff]
60
60
  target-version = "py311"
61
+
62
+ [tool.ruff.lint]
63
+ # Ruff's default rule set is not stable across releases -- 0.16 broadened it,
64
+ # which turned CI red on unchanged code. State the rules we lint against so a
65
+ # ruff upgrade changes them only when we say so. These are ruff's pre-0.16
66
+ # defaults, which is what this project has been checked against all along.
67
+ select = ["E4", "E7", "E9", "F"]
@@ -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.",
@@ -155,8 +164,9 @@ class PlotConfig:
155
164
  shift_tree_loc: Annotated[
156
165
  int,
157
166
  "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`.",
160
170
  ] = 0
161
171
 
162
172
  color_tree_by: Annotated[
@@ -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,
@@ -77,6 +78,7 @@ def plot(
77
78
  branch_length=branch_length,
78
79
  tree_size=tree_size,
79
80
  tree_location=tree_location,
81
+ spacing=spacing,
80
82
  tree_line_width=tree_line_width,
81
83
  tree_node_size=tree_node_size,
82
84
  leader_line_width=leader_line_width,
@@ -281,7 +283,10 @@ def _build(
281
283
  hoisted_config, hoisted_other = _pop_toplevel_only_attrs(new_chart)
282
284
 
283
285
  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,
285
290
  )
286
291
  _apply_combined_config(combined, hoisted_config)
287
292
  for k, v in hoisted_other.items():
@@ -320,6 +325,7 @@ def _concat_for_location(
320
325
  tree_chart: alt.TopLevelMixin,
321
326
  user_chart: alt.TopLevelMixin,
322
327
  location: TreeLocation,
328
+ spacing: int,
323
329
  ) -> alt.HConcatChart | alt.VConcatChart:
324
330
  """Concat tree and chart in the order implied by the tree's location.
325
331
 
@@ -333,19 +339,19 @@ def _concat_for_location(
333
339
  user-chart marks whose color values aren't in the tree's domain.
334
340
  """
335
341
  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(
337
343
  y="independent", color="independent"
338
344
  )
339
345
  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(
341
347
  y="independent", color="independent"
342
348
  )
343
349
  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(
345
351
  x="independent", color="independent"
346
352
  )
347
353
  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(
349
355
  x="independent", color="independent"
350
356
  )
351
357
  raise ValueError(f"unreachable: tree_location={location!r}")
@@ -1475,6 +1481,11 @@ def _build_scale_bar_layer(
1475
1481
 
1476
1482
  _LABEL_PAD_PX_MIN = 4
1477
1483
  _LABEL_PAD_RATIO = 0.4 # `LABEL_PAD_PX = max(MIN, font_size * RATIO)`
1484
+ # Gap between the labels and the chart, kept separate from the tree-facing pad
1485
+ # above because it clears the chart's frame rather than the deepest tip, and so
1486
+ # wants a small fixed clearance rather than the pad's larger, faster-growing one.
1487
+ _LABEL_CHART_GAP_PX_MIN = 3
1488
+ _LABEL_CHART_GAP_RATIO = 0.2 # `LABEL_CHART_GAP_PX = max(MIN, font_size * RATIO)`
1478
1489
  _LABEL_CHAR_PX_RATIO = 0.6 # rough proportional sans-serif glyph-width estimate
1479
1490
  _LABEL_HALO_RATIO = 0.6 # white-halo strokeWidth as a fraction of font_size
1480
1491
 
@@ -1614,9 +1625,15 @@ def _build_tree_chart(
1614
1625
  max_name_len = max((len(n) for n in names), default=0)
1615
1626
  char_px = strain_label_font_size * _LABEL_CHAR_PX_RATIO
1616
1627
  label_pad_px = max(_LABEL_PAD_PX_MIN, strain_label_font_size * _LABEL_PAD_RATIO)
1628
+ chart_gap_px = max(
1629
+ _LABEL_CHART_GAP_PX_MIN, strain_label_font_size * _LABEL_CHART_GAP_RATIO
1630
+ )
1617
1631
  # 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
1632
+ # extension on the leader-facing side, a pad between the tree and the
1633
+ # text, and a smaller gap between the text and the chart.
1634
+ label_pixel_width = (
1635
+ label_pad_px + chart_gap_px + max_name_len * char_px + halo_px / 2
1636
+ )
1620
1637
  strip_pixel_width = label_pixel_width - shift_tree_loc
1621
1638
  if strip_pixel_width <= 0:
1622
1639
  raise ValueError(
@@ -1629,9 +1646,13 @@ def _build_tree_chart(
1629
1646
  per_pixel = branch_span / tree_size if tree_size else 0.0
1630
1647
  extra_branch_units = strip_pixel_width * per_pixel
1631
1648
  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
1649
+ # Anchor the labels `chart_gap_px` *inside* the chart-facing edge rather
1650
+ # than on it, so the text does not run up against the chart's frame. The
1651
+ # leaders stop at the same place, where the text begins.
1652
+ label_anchor_branch = chart_edge_branch - chart_gap_px * per_pixel
1653
+ tips_df = tips_df.assign(x_label=label_anchor_branch)
1654
+ leader_df = tips_df[tips_df["x"] < label_anchor_branch].assign(
1655
+ x2=label_anchor_branch
1635
1656
  )
1636
1657
  extended_tree_size = tree_size + strip_pixel_width
1637
1658
  else:
@@ -1680,9 +1701,9 @@ def _build_tree_chart(
1680
1701
  # When connect_leader_to_label is on, the branch domain is extended
1681
1702
  # past `branch_max` to `chart_edge_branch` so the label strip has
1682
1703
  # 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).
1704
+ # `tree_size` on the panel. Each label's chart-facing edge sits
1705
+ # `chart_gap_px` inside `chart_edge_branch`, aligned outward (right
1706
+ # for tree on the left, left for tree on the right).
1686
1707
  if tree_location == "left":
1687
1708
  branch_domain = [branch_min, chart_edge_branch]
1688
1709
  text_align = "right"
@@ -1783,7 +1804,8 @@ def _build_tree_chart(
1783
1804
  # strip at bottom, opposite the chart above).
1784
1805
  # tree_location="bottom" → root at bottom → branch_max at top (label
1785
1806
  # strip at top, opposite the chart below).
1786
- # Each label's chart-facing edge is anchored at `chart_edge_branch`.
1807
+ # Each label's chart-facing edge sits `chart_gap_px` inside
1808
+ # `chart_edge_branch`.
1787
1809
  # The text mark is rotated 270° (reads bottom-to-top), which maps
1788
1810
  # pre-rotation `align="right"` to a top anchor (text extends down)
1789
1811
  # and `align="left"` to a bottom anchor (text extends up). For tree
@@ -795,3 +795,30 @@ def test_cli_scale_bar_font_size(tmp_path):
795
795
  collect_text_marks(tree_panel, text_marks)
796
796
  sizes = [m.get("fontSize") for m in text_marks if "fontSize" in m]
797
797
  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
@@ -45,6 +45,7 @@ def test_help_lists_auto_generated_options() -> None:
45
45
  "--branch-length",
46
46
  "--tree-size",
47
47
  "--tree-location",
48
+ "--spacing",
48
49
  "--tree-line-width",
49
50
  "--scale-bar / --no-scale-bar",
50
51
  "--strict-version / --no-strict-version",
@@ -285,9 +285,9 @@ def test_default_leader_endpoint_stops_at_branch_max() -> None:
285
285
 
286
286
  def test_vertical_left_renders_text_with_right_align() -> None:
287
287
  """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`."""
291
291
  out = tree_annotated_plot.plot(_auspice(), _vertical_chart(), **_on_kw())
292
292
  text = _text_layer(out)
293
293
  assert text is not None
@@ -557,3 +557,48 @@ def test_default_off_keeps_user_axis_labels_intact() -> None:
557
557
  assert axis.get("labelFontWeight") == "bold"
558
558
  assert axis.get("labels") is not False
559
559
  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)
@@ -181,3 +181,55 @@ def test_right_with_x_encoded_strain_raises() -> None:
181
181
  tree_annotated_plot.plot(
182
182
  _auspice_4_tip(), _horizontal_chart(), tree_location="right", **_kw()
183
183
  )
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")