phylustrator 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 (54) hide show
  1. {phylustrator-0.3.0/src/phylustrator.egg-info → phylustrator-0.4.0}/PKG-INFO +2 -2
  2. {phylustrator-0.3.0 → phylustrator-0.4.0}/README.md +1 -1
  3. {phylustrator-0.3.0 → phylustrator-0.4.0}/pyproject.toml +1 -1
  4. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/__init__.py +2 -1
  5. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/__init__.py +2 -1
  6. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/guides.py +86 -16
  7. {phylustrator-0.3.0 → phylustrator-0.4.0/src/phylustrator.egg-info}/PKG-INFO +2 -2
  8. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_figure.py +148 -0
  9. {phylustrator-0.3.0 → phylustrator-0.4.0}/LICENSE +0 -0
  10. {phylustrator-0.3.0 → phylustrator-0.4.0}/setup.cfg +0 -0
  11. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/__init__.py +0 -0
  12. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/__main__.py +0 -0
  13. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/cli.py +0 -0
  14. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/color.py +0 -0
  15. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/compose.py +0 -0
  16. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/__init__.py +0 -0
  17. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/figure.py +0 -0
  18. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/genome.py +0 -0
  19. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/io.py +0 -0
  20. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
  21. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/genes.py +0 -0
  22. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/guides.py +0 -0
  23. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
  24. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
  25. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layout.py +0 -0
  26. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/matrix.py +0 -0
  27. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/panels.py +0 -0
  28. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/track.py +0 -0
  29. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/render.py +0 -0
  30. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/style.py +0 -0
  31. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/figure.py +0 -0
  32. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/io.py +0 -0
  33. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/clades.py +0 -0
  34. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/coloring.py +0 -0
  35. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/events.py +0 -0
  36. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/labels.py +0 -0
  37. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/tracks.py +0 -0
  38. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layout.py +0 -0
  39. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/panels.py +0 -0
  40. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/skeleton.py +0 -0
  41. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/tree.py +0 -0
  42. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/zombi.py +0 -0
  43. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/SOURCES.txt +0 -0
  44. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
  45. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
  46. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/requires.txt +0 -0
  47. {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/top_level.txt +0 -0
  48. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_below.py +0 -0
  49. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_cli.py +0 -0
  50. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_genomes.py +0 -0
  51. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_io.py +0 -0
  52. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_layout.py +0 -0
  53. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_tree.py +0 -0
  54. {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
5
5
  Author-email: "Adrian A. Davin" <aaredav@gmail.com>
6
6
  License: MIT
@@ -74,7 +74,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
74
74
  - **Layouts** — `rectangular` (default), `radial`, `unrooted`.
75
75
  - **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
76
76
  `branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
77
- `highlight_clade`.
77
+ `title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
78
78
  - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
79
79
  shares the tree's time axis, so a value measured at a node sits directly under that node.
80
80
 
@@ -35,7 +35,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
35
35
  - **Layouts** — `rectangular` (default), `radial`, `unrooted`.
36
36
  - **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
37
37
  `branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
38
- `highlight_clade`.
38
+ `title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
39
39
  - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
40
40
  shares the tree's time axis, so a value measured at a node sits directly under that node.
41
41
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phylustrator"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ from .layers import (
26
26
  time_marker,
27
27
  tip_labels,
28
28
  tip_track,
29
+ title,
29
30
  )
30
31
  from .panels import NodePoints, node_points
31
32
  from .tree import Node, Tree
@@ -35,7 +36,7 @@ __all__ = [
35
36
  "plot", "Figure", "Geometry", "NodePos", "TipPos",
36
37
  "color_branches", "color_history", "color_lanes", "tip_labels", "node_labels", "tip_track", "ring",
37
38
  "rubberband",
38
- "branch_events", "colorbar", "legend", "note", "time_axis", "time_marker", "scale_bar",
39
+ "branch_events", "colorbar", "legend", "note", "time_axis", "time_marker", "scale_bar", "title",
39
40
  "highlight_clade",
40
41
  "node_points", "NodePoints",
41
42
  ]
@@ -8,7 +8,7 @@ Each layer is a callable ``(canvas, tree, layout, style) -> None``. Import them
8
8
  from .clades import highlight_clade
9
9
  from .coloring import color_branches, color_history, color_lanes
10
10
  from .events import branch_events
11
- from .guides import colorbar, legend, note, scale_bar, time_axis, time_marker
11
+ from .guides import colorbar, legend, note, scale_bar, time_axis, time_marker, title
12
12
  from .labels import node_labels, tip_labels
13
13
  from .tracks import ring, rubberband, tip_track
14
14
 
@@ -28,5 +28,6 @@ __all__ = [
28
28
  "time_axis",
29
29
  "time_marker",
30
30
  "scale_bar",
31
+ "title",
31
32
  "highlight_clade",
32
33
  ]
@@ -18,6 +18,20 @@ def _clear_behind(canvas, x, y, w, h, pad: float = 6.0) -> None:
18
18
  fill=_KEY_BACKDROP, stroke="none", stroke_width=0.0)
19
19
 
20
20
 
21
+ #: The corners a pinned layer may sit in — the four ``branch_events`` has always taken.
22
+ _CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
23
+
24
+
25
+ def _corner(loc: str, who: str) -> str:
26
+ """Check a corner name at the point it is written. ``"top-centre"`` used to fall through the
27
+ ``"left" in loc`` test and print as though it had been asked for on the right, which is worse
28
+ than an error."""
29
+ if loc not in _CORNERS:
30
+ centred = " (a centred title is `title()`)" if "cent" in loc.lower() else ""
31
+ raise ValueError(f"{who}: loc must be one of {', '.join(_CORNERS)}, not {loc!r}{centred}")
32
+ return loc
33
+
34
+
21
35
  def _text_width(text: str, size: float) -> float:
22
36
  """A serviceable width for a string at this font size — the canvas has no font metrics, and this
23
37
  only has to be close enough to clear the tree behind it."""
@@ -64,13 +78,20 @@ def colorbar(title: str = "", *, loc: str = "top-left", width: float = 130.0, he
64
78
  return layer
65
79
 
66
80
 
67
- def legend(title: str = "", *, swatch: float | None = None, size: float | None = None,
68
- entries: dict | None = None, dy: float = 0.0, inset: float | None = None):
69
- """A category swatch list, top-left. ``size`` sets the label font (default the style's) and the
70
- swatch scales with it. Reads the recorded categorical scale, or ``entries``
71
- (``{label: colour}``) to draw an explicit list — a figure whose scale slot is taken by a
72
- continuous ring still gets its categorical legend that way. ``dy`` shifts the list down, so it
73
- can sit below a ``colorbar`` on the same corner. No-op without a source of entries."""
81
+ def legend(title: str = "", *, loc: str = "top-left", swatch: float | None = None,
82
+ size: float | None = None, entries: dict | None = None, dy: float = 0.0,
83
+ inset: float | None = None):
84
+ """A category swatch list pinned to a corner — ``"top-left"`` by default, or any of the four
85
+ ``branch_events`` takes. A rectangular tree fills the upper left of its panel, so the top left
86
+ is often the one corner a legend cannot share; a right corner right-aligns the block on the
87
+ right margin, and a bottom corner ends it on the bottom margin.
88
+
89
+ ``size`` sets the label font (default the style's) and the swatch scales with it. Reads the
90
+ recorded categorical scale, or ``entries`` (``{label: colour}``) to draw an explicit list — a
91
+ figure whose scale slot is taken by a continuous ring still gets its categorical legend that
92
+ way. ``dy`` shifts the list down, so it can sit below a ``colorbar`` in the same corner. No-op
93
+ without a source of entries."""
94
+ _corner(loc, "legend")
74
95
 
75
96
  def layer(canvas, tree, layout, style):
76
97
  palette = entries
@@ -79,14 +100,22 @@ def legend(title: str = "", *, swatch: float | None = None, size: float | None =
79
100
  if not scale or scale.get("kind") != "categorical":
80
101
  return
81
102
  palette = scale["palette"]
103
+ w, h = canvas.size
82
104
  fs = size if size is not None else style.font_size
83
105
  sw = swatch if swatch is not None else fs * 0.95
84
- x = inset if inset is not None else style.margin_at("left")
85
- y = (inset if inset is not None else style.margin_at("top")) + dy
86
106
  rows = len(palette) + (1 if title else 0)
87
107
  widest = max([_text_width(str(k), fs) for k in palette] +
88
108
  [_text_width(title, fs) if title else 0.0])
89
- _clear_behind(canvas, x, y - fs, sw + 8 + widest, rows * fs * 1.65)
109
+ box_w = sw + 8 + widest
110
+
111
+ def side(name):
112
+ return inset if inset is not None else style.margin_at(name)
113
+
114
+ # the drop from the first row's baseline to the last one's; a title row is the taller one
115
+ drop = (fs * 1.7 if title else 0.0) + max(len(palette) - 1, 0) * fs * 1.6
116
+ x = side("left") if "left" in loc else w - side("right") - box_w
117
+ y = (side("top") if "top" in loc else h - side("bottom") - drop - fs * 0.5) + dy
118
+ _clear_behind(canvas, x, y - fs, box_w, rows * fs * 1.65)
90
119
  if title:
91
120
  canvas.raw_text(x, y, title, anchor="start", weight="bold", size=fs)
92
121
  y += fs * 1.7
@@ -100,15 +129,35 @@ def legend(title: str = "", *, swatch: float | None = None, size: float | None =
100
129
 
101
130
  def time_marker(*times, color: str = "#444444", width: float = 1.5, dash: bool = True,
102
131
  label: str | None = None, label_size: float | None = None):
103
- """Vertical reference line(s) crossing the tree at the given distance/time value(s) — e.g. to mark
104
- a mass-extinction moment or a rate shift. Rectangular only (distance maps to x). ``label`` (if
105
- given) is written above the first line."""
132
+ """Reference marker(s) at the given distance/time value(s) — e.g. a mass-extinction moment or a
133
+ rate shift. Rectangular: a vertical line crossing the tree. Radial: a circle at that distance
134
+ from the centre, which is what a depth threshold looks like on a tree too big to read as a
135
+ rectangle. The unrooted layout places branches by angle and has no distance-from-origin axis, so
136
+ it raises rather than drawing nothing. ``label`` (if given) is written above the first marker.
137
+
138
+ Times come on the stem-inclusive scale of the rectangular layout, the same scale
139
+ ``branch_events`` takes. The radial layout starts at the crown, so the stem comes off there."""
106
140
 
107
141
  def layer(canvas, tree, layout, style):
108
- if layout.kind != "rectangular":
142
+ ls = label_size if label_size is not None else style.font_size
143
+ if layout.kind == "unrooted":
144
+ raise ValueError("time_marker needs a layout with a distance axis, and the unrooted "
145
+ "layout has none: it places branches by angle, so a distance from the "
146
+ "origin is not a place on it. Use rectangular or radial.")
147
+ if layout.kind == "radial":
148
+ # the radial layout drops the stem and starts at the crown, so shift the times by it —
149
+ # branch_events shifts the same way, for the same reason
150
+ stem = float(tree.root.length or 0.0)
151
+ for i, t in enumerate(times):
152
+ r = t - stem
153
+ if r < 0:
154
+ raise ValueError(f"time_marker at {t:g} falls inside the root stem, which the "
155
+ f"radial layout does not draw: its centre is the crown, at {stem:g}")
156
+ canvas.data_ring(r, color, width, dash=dash)
157
+ if label and i == 0:
158
+ canvas.text(0.0, -r, label, dy=-8, anchor="middle", color=color, size=ls)
109
159
  return
110
160
  y0, y1 = layout.ylim
111
- ls = label_size if label_size is not None else style.font_size
112
161
  for i, t in enumerate(times):
113
162
  canvas.line(t, y0, t, y1, color, width, dash=dash)
114
163
  if label and i == 0:
@@ -124,7 +173,8 @@ def note(text: str, *, loc: str = "top-left", size: float | None = None,
124
173
 
125
174
  ``dy`` nudges it in pixels, negative up. The corner is fixed to the margin, which is the right
126
175
  place for a note *about* the figure; a note read as a **title** wants a little more air between
127
- it and the tree than a margin the tree also uses can give."""
176
+ it and the tree than a margin the tree also uses can give, so a centred title is :func:`title`."""
177
+ _corner(loc, "note")
128
178
 
129
179
  def layer(canvas, tree, layout, style):
130
180
  w, h = canvas.size
@@ -139,6 +189,26 @@ def note(text: str, *, loc: str = "top-left", size: float | None = None,
139
189
  return layer
140
190
 
141
191
 
192
+ def title(text: str, *, size: float | None = None, dy: float = 0.0, color: str | None = None,
193
+ weight: str = "bold"):
194
+ """A title centred over the panel, in the top margin above the tree.
195
+
196
+ ``note`` pins text to a corner, which is where something *about* a figure belongs; a title
197
+ belongs over the middle of it. ``size`` defaults to half again the style's font, ``dy`` nudges
198
+ it in pixels (negative up), and the title stays on the page even on a thin top margin. Give the
199
+ figure a larger ``margin_top`` when the title sits too close to the tree."""
200
+
201
+ def layer(canvas, tree, layout, style):
202
+ w, _ = canvas.size
203
+ fs = size if size is not None else style.font_size * 1.4
204
+ # halfway up the top margin, but never so high that the page edge cuts it
205
+ y = max(style.margin_at("top") * 0.5, fs * 0.75) + dy
206
+ canvas.raw_text(w / 2, y, text, anchor="middle", size=fs,
207
+ color=color or style.label_color, weight=weight)
208
+
209
+ return layer
210
+
211
+
142
212
  def scale_bar(length: float | None = None, label: str | None = None):
143
213
  """A short bar of a fixed distance, bottom-right — the branch-length key for any layout. Defaults
144
214
  to a round fraction of the tree's extent. Returns a layer."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
5
5
  Author-email: "Adrian A. Davin" <aaredav@gmail.com>
6
6
  License: MIT
@@ -74,7 +74,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
74
74
  - **Layouts** — `rectangular` (default), `radial`, `unrooted`.
75
75
  - **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
76
76
  `branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
77
- `highlight_clade`.
77
+ `title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
78
78
  - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
79
79
  shares the tree's time axis, so a value measured at a node sits directly under that node.
80
80
 
@@ -1,9 +1,11 @@
1
1
  """Figure: the skeleton renders to SVG, and the stem shows up as one extra branch."""
2
2
 
3
+ import math
3
4
  import re
4
5
 
5
6
  import pytest
6
7
 
8
+ from phylustrator import Style
7
9
  from phylustrator.trees import (
8
10
  branch_events,
9
11
  color_branches,
@@ -15,6 +17,8 @@ from phylustrator.trees import (
15
17
  note,
16
18
  plot,
17
19
  rubberband,
20
+ time_marker,
21
+ title,
18
22
  )
19
23
 
20
24
 
@@ -231,3 +235,147 @@ def test_rubberband_survives_a_lopsided_tree_and_stays_smooth():
231
235
  + rubberband(pops, palette=palette, gap=8.0, smooth=smooth)).as_svg()
232
236
  assert svg.count("<path") > 0
233
237
  assert "#207080" in svg and "#c77bad" in svg
238
+
239
+
240
+ # --- time_marker across the layouts (issue #8) ------------------------------------------------
241
+
242
+ _MARKER_TREE = "((((A:1,B:1)F:1,C:2)G:1,D:3)H:1,E:4)R;"
243
+
244
+
245
+ def _circle(svg):
246
+ """The (cx, cy, r) of the one circle in the figure — the radial marker."""
247
+ found = re.findall(r'<circle cx="([-\d.]+)" cy="([-\d.]+)" r="([-\d.]+)"', svg)
248
+ assert len(found) == 1, f"expected one circle, found {len(found)}"
249
+ return tuple(float(v) for v in found[0])
250
+
251
+
252
+ def test_time_marker_draws_in_rectangular_and_radial():
253
+ """It rendered nothing at all in radial, and said nothing about it."""
254
+ tree = loads(_MARKER_TREE)
255
+ for layout in ("rectangular", "radial"):
256
+ svg = (plot(tree, layout=layout) + time_marker(2.0, color="#D55E00", width=4)).as_svg()
257
+ assert "#D55E00" in svg, f"the marker is missing from the {layout} layout"
258
+
259
+
260
+ def test_the_radial_marker_is_a_circle_through_the_nodes_at_that_distance():
261
+ tree = loads(_MARKER_TREE)
262
+ fig = plot(tree, layout="radial") + time_marker(2.0, color="#D55E00")
263
+ cx, cy, r = _circle(fig.as_svg())
264
+ at_two = [n for n in fig.geometry().nodes if n.name == "G"][0] # G sits at distance 2
265
+ assert math.hypot(at_two.x - cx, at_two.y - cy) == pytest.approx(r)
266
+ assert r > 0
267
+
268
+
269
+ def test_the_radial_marker_takes_the_stem_off_the_time():
270
+ """The radial layout starts at the crown, as branch_events already assumed for its events."""
271
+ tree = loads("((((A:1,B:1)F:1,C:2)G:1,D:3)H:1,E:4)R:1.5;")
272
+ fig = plot(tree, layout="radial") + time_marker(3.5) # 2.0 past the crown
273
+ cx, cy, r = _circle(fig.as_svg())
274
+ at_two = [n for n in fig.geometry().nodes if n.name == "G"][0]
275
+ assert math.hypot(at_two.x - cx, at_two.y - cy) == pytest.approx(r)
276
+
277
+
278
+ def test_the_radial_marker_keeps_its_dash():
279
+ tree = loads(_MARKER_TREE)
280
+ assert "stroke-dasharray" in (plot(tree, layout="radial") + time_marker(2.0)).as_svg()
281
+ assert "stroke-dasharray" not in (plot(tree, layout="radial")
282
+ + time_marker(2.0, dash=False)).as_svg()
283
+
284
+
285
+ def test_time_marker_refuses_the_unrooted_layout():
286
+ """A distance from the origin is not a place on an equal-angle layout. Say so, do not skip."""
287
+ with pytest.raises(ValueError, match="unrooted"):
288
+ (plot(loads(_MARKER_TREE), layout="unrooted") + time_marker(2.0)).as_svg()
289
+
290
+
291
+ def test_time_marker_refuses_a_time_the_radial_layout_cannot_show():
292
+ with pytest.raises(ValueError, match="stem"):
293
+ (plot(loads("((A:1,B:1)F:1,C:2)R:1.5;"), layout="radial") + time_marker(0.5)).as_svg()
294
+
295
+
296
+ # --- a centred title, and a legend in any corner (issues #9 and #10) ---------------------------
297
+
298
+ _PLACED = Style(width=400, height=300, margin=20)
299
+ _ENTRIES = {"a": "#111111", "b": "#222222"}
300
+ _CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
301
+
302
+
303
+ def _rect(svg, fill):
304
+ found = re.search(rf'<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" '
305
+ rf'fill="{fill}"', svg)
306
+ assert found, f"no rect filled {fill}"
307
+ return tuple(float(v) for v in found.groups())
308
+
309
+
310
+ def _swatches(svg):
311
+ return [_rect(svg, fill) for fill in _ENTRIES.values()]
312
+
313
+
314
+ def _backdrop(svg):
315
+ """The white patch the legend paints behind itself — the block's own box."""
316
+ boxes = re.findall(r'<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" '
317
+ r'fill="#ffffff"', svg)
318
+ boxes = [tuple(float(v) for v in b) for b in boxes if (float(b[0]), float(b[1])) != (0.0, 0.0)]
319
+ assert len(boxes) == 1, f"expected one backdrop, found {len(boxes)}"
320
+ return boxes[0]
321
+
322
+
323
+ def _legend_svg(loc):
324
+ return (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED)
325
+ + legend("state", loc=loc, entries=_ENTRIES)).as_svg()
326
+
327
+
328
+ def test_title_sits_centred_over_the_panel():
329
+ """note pins text to a corner; a title belongs over the middle of the figure."""
330
+ svg = (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED) + title("Primates")).as_svg()
331
+ found = re.search(r'<text x="([-\d.]+)" y="([-\d.]+)"[^>]*text-anchor="(\w+)"[^>]*>Primates</text>', svg)
332
+ assert found
333
+ x, y, anchor = float(found.group(1)), float(found.group(2)), found.group(3)
334
+ assert x == pytest.approx(_PLACED.width / 2) and anchor == "middle"
335
+ assert y < _PLACED.margin, "the title should sit in the top margin, above the tree"
336
+
337
+
338
+ def test_a_title_stays_on_the_page_when_the_top_margin_is_thin():
339
+ svg = (plot(loads("(A:1,B:1)R;"), style=Style(width=400, height=300, margin=4))
340
+ + title("Primates", size=20)).as_svg()
341
+ y = float(re.search(r'<text x="[-\d.]+" y="([-\d.]+)"[^>]*>Primates</text>', svg).group(1))
342
+ assert y >= 10.0, "half the title would be cut off by the top edge"
343
+
344
+
345
+ def test_note_refuses_a_loc_that_is_not_a_corner():
346
+ """`loc="top-centre"` used to fall through and print right-aligned instead."""
347
+ with pytest.raises(ValueError, match="title"):
348
+ note("Primates", loc="top-centre")
349
+ with pytest.raises(ValueError, match="top-left"):
350
+ note("Primates", loc="top")
351
+
352
+
353
+ def test_the_legend_default_corner_is_unchanged():
354
+ assert _legend_svg("top-left") == (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED)
355
+ + legend("state", entries=_ENTRIES)).as_svg()
356
+
357
+
358
+ def test_the_legend_sits_in_the_corner_it_is_given():
359
+ """branch_events has always taken the four corners; legend was pinned to the top left."""
360
+ top_left = _swatches(_legend_svg("top-left"))
361
+ for loc in _CORNERS:
362
+ svg = _legend_svg(loc)
363
+ swatches = _swatches(svg)
364
+ bx, by, bw, bh = _backdrop(svg)
365
+ assert bx >= 0 and by >= 0, f"{loc}: the legend runs off the page"
366
+ assert bx + bw <= _PLACED.width and by + bh <= _PLACED.height, f"{loc}: off the page"
367
+ if "left" in loc:
368
+ assert swatches[0][0] == pytest.approx(_PLACED.margin), loc
369
+ else:
370
+ # the block is right-aligned on the right margin (the backdrop adds 6px of padding)
371
+ assert bx + bw - 6 == pytest.approx(_PLACED.width - _PLACED.margin), loc
372
+ if "top" in loc:
373
+ assert swatches[0][1] == pytest.approx(top_left[0][1]), loc
374
+ else:
375
+ lowest = max(y + h for _, y, _, h in swatches)
376
+ assert lowest == pytest.approx(_PLACED.height - _PLACED.margin, abs=1.0), loc
377
+
378
+
379
+ def test_legend_refuses_a_loc_that_is_not_a_corner():
380
+ with pytest.raises(ValueError, match="bottom-right"):
381
+ legend("state", loc="middle", entries=_ENTRIES)
File without changes
File without changes