phylustrator 0.2.17__tar.gz → 0.3.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 (55) hide show
  1. {phylustrator-0.2.17/src/phylustrator.egg-info → phylustrator-0.3.0}/PKG-INFO +3 -1
  2. {phylustrator-0.2.17 → phylustrator-0.3.0}/README.md +2 -0
  3. {phylustrator-0.2.17 → phylustrator-0.3.0}/pyproject.toml +1 -1
  4. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/__init__.py +4 -3
  5. phylustrator-0.3.0/src/phylustrator/compose.py +121 -0
  6. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/figure.py +3 -4
  7. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/guides.py +1 -2
  8. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/render.py +16 -8
  9. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/style.py +18 -0
  10. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/__init__.py +4 -2
  11. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/figure.py +37 -7
  12. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/events.py +2 -3
  13. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/guides.py +46 -35
  14. phylustrator-0.3.0/src/phylustrator/trees/panels.py +109 -0
  15. {phylustrator-0.2.17 → phylustrator-0.3.0/src/phylustrator.egg-info}/PKG-INFO +3 -1
  16. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/SOURCES.txt +2 -0
  17. phylustrator-0.3.0/tests/test_below.py +137 -0
  18. phylustrator-0.2.17/src/phylustrator/compose.py +0 -66
  19. {phylustrator-0.2.17 → phylustrator-0.3.0}/LICENSE +0 -0
  20. {phylustrator-0.2.17 → phylustrator-0.3.0}/setup.cfg +0 -0
  21. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/__main__.py +0 -0
  22. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/cli.py +0 -0
  23. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/color.py +0 -0
  24. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/__init__.py +0 -0
  25. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/genome.py +0 -0
  26. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/io.py +0 -0
  27. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
  28. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/genes.py +0 -0
  29. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
  30. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
  31. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layout.py +0 -0
  32. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/matrix.py +0 -0
  33. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/panels.py +0 -0
  34. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/track.py +0 -0
  35. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/io.py +0 -0
  36. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/__init__.py +0 -0
  37. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/clades.py +0 -0
  38. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/coloring.py +0 -0
  39. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/labels.py +0 -0
  40. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/tracks.py +0 -0
  41. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layout.py +0 -0
  42. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/skeleton.py +0 -0
  43. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/tree.py +0 -0
  44. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/zombi.py +0 -0
  45. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
  46. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
  47. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/requires.txt +0 -0
  48. {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/top_level.txt +0 -0
  49. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_cli.py +0 -0
  50. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_figure.py +0 -0
  51. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_genomes.py +0 -0
  52. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_io.py +0 -0
  53. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_layout.py +0 -0
  54. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_tree.py +0 -0
  55. {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.2.17
3
+ Version: 0.3.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
@@ -75,6 +75,8 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
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
77
  `highlight_clade`.
78
+ - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
79
+ shares the tree's time axis, so a value measured at a node sits directly under that node.
78
80
 
79
81
  ## Genomes
80
82
 
@@ -36,6 +36,8 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
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
38
  `highlight_clade`.
39
+ - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
40
+ shares the tree's time axis, so a value measured at a node sits directly under that node.
39
41
 
40
42
  ## Genomes
41
43
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phylustrator"
7
- version = "0.2.17"
7
+ version = "0.3.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"
@@ -12,8 +12,9 @@ Two domains, one grammar (``plot(x) + layer + …``), one shared drawing backend
12
12
  G = ph.zombi.read_genomes("run")
13
13
  (ph.genomes.plot(G["n12"], layout="circular") + ph.genomes.genes(by="family")).save("ring.png")
14
14
 
15
- # bridge: a matrix beside a tree
15
+ # bridge: a matrix beside a tree, or a per-node value below it on the same time axis
16
16
  ph.beside(ph.trees.plot(tree) + ph.trees.tip_labels(), ph.genomes.heatmap(ph.zombi.read_profiles("run")))
17
+ ph.below(ph.trees.plot(tree), ph.trees.node_points({"C": 1.5, "R": -0.5}))
17
18
 
18
19
  ``ph.zombi`` is the only ZOMBI2-format-aware module; ``trees`` / ``genomes`` are general.
19
20
  """
@@ -24,7 +25,7 @@ from importlib.metadata import PackageNotFoundError
24
25
  from importlib.metadata import version as _pkg_version
25
26
 
26
27
  from . import genomes, trees, zombi
27
- from .compose import Composite, beside
28
+ from .compose import Composite, below, beside
28
29
  from .style import Style
29
30
 
30
31
  try: # single source of truth is pyproject.toml; read it from the installed metadata
@@ -32,4 +33,4 @@ try: # single source of truth is pyproject.toml; read it from the installed met
32
33
  except PackageNotFoundError: # a source tree that hasn't been installed
33
34
  __version__ = "0.0.0+unknown"
34
35
 
35
- __all__ = ["trees", "genomes", "zombi", "beside", "Composite", "Style", "__version__"]
36
+ __all__ = ["trees", "genomes", "zombi", "beside", "below", "Composite", "Style", "__version__"]
@@ -0,0 +1,121 @@
1
+ """Composition — put a panel beside a Phylustrator tree, rows lined up with the tips, or below it,
2
+ lined up with the time axis.
3
+
4
+ ``beside(tree, panel)`` takes a **Phylustrator** figure (the phylogeny — Phylustrator draws it, we do
5
+ not redraw it) and a panel (``heatmap`` / ``alignment``). It asks the tree for its tip pixel positions
6
+ (``Figure.geometry``), renders the tree into the left column, and draws the panel to the right with
7
+ each row at its tip's ``y`` — so a genome's row sits exactly on its leaf.
8
+
9
+ ``below(tree, panel)`` is its x-axis twin: the panel (``trees.node_points``) sits under the tree and
10
+ places each value at its node's pixel ``x``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import replace
16
+
17
+ from .render import Canvas
18
+ from .style import Style
19
+ from .trees.layers.guides import draw_time_axis
20
+
21
+
22
+ class Composite:
23
+ """The rendered composite; save like any figure."""
24
+
25
+ def __init__(self, canvas: Canvas) -> None:
26
+ self._canvas = canvas
27
+
28
+ def as_svg(self) -> str:
29
+ return self._canvas.as_svg()
30
+
31
+ def save(self, path):
32
+ return self._canvas.save(path)
33
+
34
+
35
+ def beside(tree, panel, *, width: float = 1100.0, height: float | None = None,
36
+ tree_fraction: float = 0.4, gap: float = 18.0, pad: float = 34.0,
37
+ footer: float = 0.0, background: str = "white") -> Composite:
38
+ """Render Phylustrator ``tree`` on the left and ``panel`` on the right, rows aligned to the tips.
39
+
40
+ ``tree_fraction`` is the share of the width given to the tree column (the rest, minus ``gap`` and
41
+ ``pad``, holds the panel). ``footer`` reserves blank height below the rows (for a panel's colour
42
+ key). Rows are matched to tips by label and drawn in the tree's tip order. Needs ``cairosvg``."""
43
+ try:
44
+ import cairosvg
45
+ except ImportError as exc: # pragma: no cover
46
+ raise RuntimeError("genustrator.beside needs cairosvg (pip install genustrator[export]) "
47
+ "to place the tree into the composite") from exc
48
+
49
+ n_tips = len(tree.tree.leaves)
50
+ # per-tip row height eases from ~44px (few tips) down to ~24px (many), so 25-40 rows stay sane
51
+ row_px = max(24.0, 46.0 - 0.55 * n_tips)
52
+ H = height if height is not None else max(260.0, 70.0 + row_px * n_tips) + footer
53
+ tree_w = round(width * tree_fraction)
54
+ tree_h = H - footer # tips fill the area above the footer
55
+
56
+ sized = tree.with_size(tree_w, tree_h)
57
+ geom = sized.geometry()
58
+ png = cairosvg.svg2png(bytestring=sized.as_svg().encode(),
59
+ output_width=int(tree_w * 2), output_height=int(tree_h * 2))
60
+
61
+ canvas = Canvas(Style(width=width, height=H, margin=0, background=background), (0.0, 1.0), (0.0, 1.0))
62
+ canvas.embed_png(png, 0, 0, tree_w, tree_h)
63
+
64
+ # A panel that wants **every** tip row says so, and gets them all — including tips it has no
65
+ # value for. `Bars` does: its axis sits under the last row it is handed, and filtering the rows
66
+ # down to the ones it has values for put that axis under the last *valued* tip instead of under
67
+ # the tree, so a tree with extinct tips below the last bar drew its two axes at different heights.
68
+ wanted = set(panel.rows)
69
+ rows = [(t.name, t.y) for t in geom.tips
70
+ if getattr(panel, "all_rows", False) or t.name in wanted]
71
+ if rows:
72
+ panel.draw(canvas, tree_w + gap, width - pad, rows, canvas.style)
73
+ return Composite(canvas)
74
+
75
+
76
+ def below(tree, panel, *, panel_height: float = 200.0, gap: float = 18.0, axis: str | None = "Time",
77
+ ticks: int = 5) -> Composite:
78
+ """Render Phylustrator ``tree`` with ``panel`` underneath, both on the tree's x axis.
79
+
80
+ The x-axis twin of :func:`beside`: a value at a node sits directly under that node (see
81
+ :func:`~phylustrator.trees.node_points`). The tree keeps its own style — width, height, margins —
82
+ and every node lands on the pixel it has in the tree alone. The page grows by ``gap`` plus
83
+ ``panel_height``, and the panel spans the tree's left and right pixel edges.
84
+
85
+ ``axis`` labels a time axis under the panel; ``None`` draws none. Leave ``time_axis`` off the
86
+ tree itself: anything a tree layer puts in its bottom margin (``time_axis``, ``scale_bar``, a
87
+ bottom-left ``colorbar``) lands in the panel. The panel's y axis sits in the tree's left margin,
88
+ so widen ``margin_left`` in the tree's ``Style`` for long tick labels or a y label.
89
+
90
+ Rectangular trees only, since that is the layout where x is time. The composite is drawn as
91
+ vectors on one canvas, so it needs no cairosvg (unlike :func:`beside`)."""
92
+ if tree.layout != "rectangular":
93
+ raise ValueError(f"below() needs a rectangular tree, where x is time; got layout={tree.layout!r}")
94
+ if not hasattr(panel, "draw_below"):
95
+ raise TypeError(f"below() takes a panel lined up with the tree's x axis, such as trees.node_points; "
96
+ f"{type(panel).__name__} is not one (a row panel goes in beside())")
97
+ style = tree.style
98
+ ts, ls = style.font_size * 0.85, style.font_size
99
+ plot_bottom = style.height - style.margin_at("bottom") # where the lowest tip sits in the tree alone
100
+ top = plot_bottom + gap
101
+ bottom = top + panel_height
102
+ # Under the panel: the tree's bottom margin, or more when the axis needs it. The axis label is
103
+ # centred 14 + ts + ls + 4 below the panel, so a 40px margin cut the label in half.
104
+ if axis is None:
105
+ axis_room = 0.0
106
+ elif axis:
107
+ axis_room = 14 + ts + ls + 4 + ls / 2 + 6
108
+ else: # ticks, no label
109
+ axis_room = 14 + ts + 3 + ts / 2 + 6
110
+ height = bottom + max(style.margin_at("bottom"), axis_room)
111
+ # The same tree on a taller page, with all the extra height given to its bottom margin: the
112
+ # nodes keep their pixels, and the panel is drawn into that margin on the same canvas.
113
+ sized = tree.with_style(replace(style, height=height, margin_bottom=height - plot_bottom))
114
+ geom = sized.geometry()
115
+ canvas = sized._build()
116
+ panel.draw_below(canvas, geom, top, bottom, style)
117
+ if axis is not None:
118
+ # +14 below the panel, the offset trees.time_axis leaves below a tree
119
+ draw_time_axis(canvas, geom.px, geom.xlim[1], bottom + 14, axis, ticks=ticks,
120
+ tick_size=ts, label_size=ls)
121
+ return Composite(canvas)
@@ -173,14 +173,13 @@ class GridFigure(Figure):
173
173
  def _build(self) -> Canvas:
174
174
  from ..color import colormap, to_hex
175
175
 
176
- m = self.style.margin
177
176
  canvas = Canvas(self.style, (0.0, 1.0), (0.0, 1.0))
178
177
  nrow, ncol = len(self.matrix.rows), len(self.matrix.cols)
179
178
  if not nrow or not ncol:
180
179
  return canvas
181
- x0, y0 = m, m
182
- w = self.style.width - 2 * m
183
- h = self.style.height - 2 * m
180
+ x0, y0 = self.style.margin_at("left"), self.style.margin_at("top")
181
+ w = self.style.width - x0 - self.style.margin_at("right")
182
+ h = self.style.height - y0 - self.style.margin_at("bottom")
184
183
  cw, ch = w / ncol, h / nrow
185
184
 
186
185
  sample = colormap(self.cmap)
@@ -25,9 +25,8 @@ def position_axis(label: str = "position", *, ticks: int = 6):
25
25
 
26
26
  def _linear(canvas, layout, style, label, ticks) -> None:
27
27
  _, height = canvas.size
28
- m = style.margin
29
28
  x0, x1 = layout.xlim
30
- y = height - m * 0.62
29
+ y = height - style.margin_at("bottom") * 0.62
31
30
  canvas.raw_line(canvas.px(x0), y, canvas.px(x1), y, "#333333", 1.2)
32
31
  small = style.font_size * 0.85
33
32
  for i in range(ticks):
@@ -10,6 +10,7 @@ through cairosvg, falling back to ``.svg`` (with a note) when it is absent.
10
10
  from __future__ import annotations
11
11
 
12
12
  import math
13
+ from collections.abc import Collection
13
14
  from pathlib import Path
14
15
 
15
16
  import drawsvg as draw
@@ -25,22 +26,29 @@ class Canvas:
25
26
  *, equal_aspect: bool = False) -> None:
26
27
  self.style = style
27
28
  self.scale = None # set by a colouring layer; read by colorbar()/legend()
29
+ # node names whose branch is dashed; set by the tree figure, read by a colouring layer
30
+ self.dashed: Collection[str] | None = None
28
31
  self._d = draw.Drawing(style.width, style.height, origin=(0, 0))
29
32
  if style.background:
30
33
  self._d.append(draw.Rectangle(0, 0, style.width, style.height, fill=style.background))
31
34
  self._x0, self._x1 = xlim
32
35
  self._y0, self._y1 = ylim
33
- self._m = style.margin
34
- self._top = style.margin + getattr(style, "headroom", 0.0)
36
+ self._left = style.margin_at("left")
37
+ self._right = style.margin_at("right")
38
+ self._bottom = style.margin_at("bottom")
39
+ self._top = style.margin_at("top") + getattr(style, "headroom", 0.0)
35
40
  # equal_aspect keeps circles round (radial/unrooted): one scale for x and y, centred.
36
41
  self._equal = None
37
42
  if equal_aspect:
38
43
  xspan = (self._x1 - self._x0) or 1.0
39
44
  yspan = (self._y1 - self._y0) or 1.0
40
- s = min((style.width - 2 * self._m) / xspan,
41
- (style.height - self._m - self._top) / yspan)
42
- self._equal = (s, style.width / 2 - s * (self._x0 + self._x1) / 2,
43
- style.height / 2 - s * (self._y0 + self._y1) / 2)
45
+ s = min((style.width - self._left - self._right) / xspan,
46
+ (style.height - self._bottom - self._top) / yspan)
47
+ # centred between the margins; headroom shrinks the scale but does not shift the centre
48
+ cx = (self._left + style.width - self._right) / 2
49
+ cy = (style.margin_at("top") + style.height - self._bottom) / 2
50
+ self._equal = (s, cx - s * (self._x0 + self._x1) / 2,
51
+ cy - s * (self._y0 + self._y1) / 2)
44
52
 
45
53
  # --- data-space (transformed through the layout extent) ---------------
46
54
 
@@ -49,14 +57,14 @@ class Canvas:
49
57
  s, ox, _ = self._equal
50
58
  return ox + s * x
51
59
  span = (self._x1 - self._x0) or 1.0
52
- return self._m + (x - self._x0) / span * (self.style.width - 2 * self._m)
60
+ return self._left + (x - self._x0) / span * (self.style.width - self._left - self._right)
53
61
 
54
62
  def py(self, y: float) -> float:
55
63
  if self._equal:
56
64
  s, _, oy = self._equal
57
65
  return oy + s * y
58
66
  span = (self._y1 - self._y0) or 1.0
59
- return self._top + (y - self._y0) / span * (self.style.height - self._m - self._top)
67
+ return self._top + (y - self._y0) / span * (self.style.height - self._bottom - self._top)
60
68
 
61
69
  def line(self, x1, y1, x2, y2, color: str, width: float, *, dash: bool = False) -> None:
62
70
  extra = {"stroke_dasharray": "5,4"} if dash else {}
@@ -14,6 +14,13 @@ class Style:
14
14
  width: float = 800.0
15
15
  height: float = 600.0
16
16
  margin: float = 50.0
17
+ #: one side's margin, when it should differ from ``margin``; ``None`` takes ``margin``. A
18
+ #: composite reserves room on one side — a y axis on the left, a panel underneath — without
19
+ #: moving the other three. Read a side through :meth:`margin_at`, never the field itself.
20
+ margin_left: float | None = None
21
+ margin_right: float | None = None
22
+ margin_top: float | None = None
23
+ margin_bottom: float | None = None
17
24
  #: extra space above the plot, on top of ``margin``. A colorbar or legend is drawn inside
18
25
  #: the plotting area, so on a tree whose first branch reaches the top corner the two share
19
26
  #: a row; this pushes the tree down instead of moving the key somewhere worse.
@@ -34,3 +41,14 @@ class Style:
34
41
  font_size: float = 12.0
35
42
  label_color: str = "#222222"
36
43
  background: str | None = "white"
44
+
45
+ def margin_at(self, side: str) -> float:
46
+ """The margin on ``side`` (``"left"`` / ``"right"`` / ``"top"`` / ``"bottom"``): that side's
47
+ own value if set, else ``margin``.
48
+
49
+ Resolved on every read, not once in ``__post_init__``: ``replace(style, margin=10)`` copies
50
+ the side fields as they are, so a side filled in from the old ``margin`` would keep it."""
51
+ if side not in ("left", "right", "top", "bottom"):
52
+ raise ValueError(f"side must be 'left', 'right', 'top' or 'bottom', got {side!r}")
53
+ own = getattr(self, f"margin_{side}")
54
+ return self.margin if own is None else own
@@ -7,7 +7,7 @@
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from .figure import Figure, Geometry, TipPos, plot
10
+ from .figure import Figure, Geometry, NodePos, TipPos, plot
11
11
  from .io import dumps, loads, read, write
12
12
  from .layers import (
13
13
  branch_events,
@@ -27,13 +27,15 @@ from .layers import (
27
27
  tip_labels,
28
28
  tip_track,
29
29
  )
30
+ from .panels import NodePoints, node_points
30
31
  from .tree import Node, Tree
31
32
 
32
33
  __all__ = [
33
34
  "Node", "Tree", "read", "loads", "write", "dumps",
34
- "plot", "Figure", "Geometry", "TipPos",
35
+ "plot", "Figure", "Geometry", "NodePos", "TipPos",
35
36
  "color_branches", "color_history", "color_lanes", "tip_labels", "node_labels", "tip_track", "ring",
36
37
  "rubberband",
37
38
  "branch_events", "colorbar", "legend", "note", "time_axis", "time_marker", "scale_bar",
38
39
  "highlight_clade",
40
+ "node_points", "NodePoints",
39
41
  ]
@@ -12,7 +12,7 @@ contract, so new decorations never touch the figure.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- from dataclasses import dataclass, replace
15
+ from dataclasses import dataclass, field, replace
16
16
  from typing import Callable
17
17
 
18
18
  from ..render import Canvas
@@ -35,15 +35,39 @@ class TipPos:
35
35
  y: float
36
36
 
37
37
 
38
+ @dataclass
39
+ class NodePos:
40
+ """Where any node — a tip or an internal node — lands on the rendered page, in pixels."""
41
+
42
+ name: str
43
+ x: float
44
+ y: float
45
+ is_leaf: bool
46
+
47
+
38
48
  @dataclass
39
49
  class Geometry:
40
50
  """The rendered figure's pixel geometry — enough to align something else (a heatmap, an
41
51
  alignment) to the tree's tips without redrawing the tree. ``tips`` are in top-to-bottom order;
42
- ``tip_x`` is the pixel x where the tips end (where a companion panel can begin)."""
52
+ ``tip_x`` is the pixel x where the tips end (where a companion panel can begin).
53
+
54
+ The x axis too: ``nodes`` holds every node, internal ones included, in preorder, and :meth:`px`
55
+ turns a layout x (a time, under the rectangular layout) into a pixel x. ``xlim`` is the layout's
56
+ x range and ``x_pixels`` its two ends on the page — where a panel under the tree starts and stops
57
+ (see :func:`~phylustrator.compose.below`)."""
43
58
 
44
59
  size: tuple[float, float]
45
60
  tips: list[TipPos]
46
61
  tip_x: float
62
+ nodes: list[NodePos] = field(default_factory=list)
63
+ xlim: tuple[float, float] = (0.0, 1.0)
64
+ x_pixels: tuple[float, float] = (0.0, 1.0)
65
+
66
+ def px(self, x: float) -> float:
67
+ """The pixel x of layout x ``x``. The canvas maps x linearly in every layout, so two points
68
+ fix it."""
69
+ (x0, x1), (p0, p1) = self.xlim, self.x_pixels
70
+ return p0 + (x - x0) / ((x1 - x0) or 1.0) * (p1 - p0)
47
71
 
48
72
 
49
73
  class Figure:
@@ -73,20 +97,26 @@ class Figure:
73
97
  def with_size(self, width: float, height: float) -> "Figure":
74
98
  """A copy of this figure rendered at a given pixel size (same tree, layers, style otherwise).
75
99
  Used to fit the tree into a column beside a companion panel."""
76
- return Figure(self.tree, layout=self.layout, stem=self.stem,
77
- style=replace(self.style, width=width, height=height),
100
+ return self.with_style(replace(self.style, width=width, height=height))
101
+
102
+ def with_style(self, style: Style) -> "Figure":
103
+ """A copy of this figure drawn with ``style`` (same tree, layers and options otherwise)."""
104
+ return Figure(self.tree, layout=self.layout, stem=self.stem, style=style,
78
105
  dashed=self.dashed, skeleton=self.skeleton, layers=self.layers)
79
106
 
80
107
  def geometry(self) -> Geometry:
81
- """The pixel positions of the tips for this figure's current style — so a companion panel can
82
- line its rows up with the tree without redrawing it."""
108
+ """The pixel positions of the tips and of every node for this figure's current style, and
109
+ the x-to-pixel mapping — so a companion panel can line up with the tree without redrawing it."""
83
110
  layout = _LAYOUTS[self.layout](self.tree, stem=self.stem)
84
111
  canvas = Canvas(self.style, layout.xlim, layout.ylim,
85
112
  equal_aspect=(self.layout != "rectangular"))
86
113
  tips = [TipPos(leaf.name or "", canvas.px(layout.x(leaf)), canvas.py(layout.y(leaf)))
87
114
  for leaf in self.tree.leaves]
88
115
  tip_x = max((t.x for t in tips), default=canvas.size[0])
89
- return Geometry(canvas.size, tips, tip_x)
116
+ nodes = [NodePos(node.name or "", canvas.px(layout.x(node)), canvas.py(layout.y(node)), node.is_leaf)
117
+ for node in self.tree.walk()]
118
+ x_pixels = (canvas.px(layout.xlim[0]), canvas.px(layout.xlim[1]))
119
+ return Geometry(canvas.size, tips, tip_x, nodes, layout.xlim, x_pixels)
90
120
 
91
121
  def _build(self) -> Canvas:
92
122
  layout = _LAYOUTS[self.layout](self.tree, stem=self.stem)
@@ -116,15 +116,14 @@ def branch_events(events, *, styles: dict | None = None, size: float = 5.5,
116
116
 
117
117
  def _draw_legend(canvas, style, used, title, marker, loc, fsize) -> None:
118
118
  width, height = canvas.size
119
- m = style.margin
120
119
  fs = fsize if fsize is not None else style.font_size
121
120
  ms = marker * (fs / style.font_size) # glyphs scale with the legend text
122
121
  row_h = fs * 1.7
123
122
  labels = ([title] if title else []) + list(used)
124
123
  box_w = ms * 2 + 14 + max(len(s) for s in labels) * fs * 0.62
125
124
  n_rows = len(used) + (1 if title else 0)
126
- x = (m + ms + 6) if "left" in loc else (width - m - box_w)
127
- y = (m * 0.6 + fs) if "top" in loc else (height - m - row_h * n_rows)
125
+ x = (style.margin_at("left") + ms + 6) if "left" in loc else (width - style.margin_at("right") - box_w)
126
+ y = (style.margin_at("top") * 0.6 + fs) if "top" in loc else (height - style.margin_at("bottom") - row_h * n_rows)
128
127
  if title:
129
128
  canvas.raw_text(x, y, title, anchor="start", weight="bold", size=fs)
130
129
  y += fs * 1.8
@@ -41,14 +41,15 @@ def colorbar(title: str = "", *, loc: str = "top-left", width: float = 130.0, he
41
41
  if not scale or scale.get("kind") != "continuous":
42
42
  return
43
43
  _, h = canvas.size
44
- m = inset if inset is not None else style.margin
44
+ left, top, bottom = ((inset, inset, inset) if inset is not None else
45
+ (style.margin_at("left"), style.margin_at("top"), style.margin_at("bottom")))
45
46
  fs = size if size is not None else style.font_size
46
- x = m
47
+ x = left
47
48
  if "bottom" in loc:
48
- y = h - m * 0.5 - height - fs * 0.9 # bar top, leaving room for the min/max labels
49
+ y = h - bottom * 0.5 - height - fs * 0.9 # bar top, leaving room for the min/max labels
49
50
  title_y = y - fs * 0.6 - 2
50
51
  else:
51
- y, title_y = m + fs, m - 2
52
+ y, title_y = top + fs, top - 2
52
53
  lo, hi = labels if labels else (f"{scale['vmin']:.2f}", f"{scale['vmax']:.2f}")
53
54
  top = min(title_y - fs, y) if title else y
54
55
  bottom = y + height + fs * 1.1
@@ -78,10 +79,10 @@ def legend(title: str = "", *, swatch: float | None = None, size: float | None =
78
79
  if not scale or scale.get("kind") != "categorical":
79
80
  return
80
81
  palette = scale["palette"]
81
- m = inset if inset is not None else style.margin
82
82
  fs = size if size is not None else style.font_size
83
83
  sw = swatch if swatch is not None else fs * 0.95
84
- x, y = m, m + dy
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
85
86
  rows = len(palette) + (1 if title else 0)
86
87
  widest = max([_text_width(str(k), fs) for k in palette] +
87
88
  [_text_width(title, fs) if title else 0.0])
@@ -127,10 +128,9 @@ def note(text: str, *, loc: str = "top-left", size: float | None = None,
127
128
 
128
129
  def layer(canvas, tree, layout, style):
129
130
  w, h = canvas.size
130
- m = style.margin
131
131
  fs = size if size is not None else style.font_size
132
- x = m if "left" in loc else w - m
133
- y = (m * 0.6 + fs) if "top" in loc else (h - m * 0.5)
132
+ x = style.margin_at("left") if "left" in loc else w - style.margin_at("right")
133
+ y = (style.margin_at("top") * 0.6 + fs) if "top" in loc else (h - style.margin_at("bottom") * 0.5)
134
134
  y += dy
135
135
  anchor = "start" if "left" in loc else "end"
136
136
  canvas.raw_text(x, y, text, anchor=anchor, size=fs,
@@ -145,11 +145,10 @@ def scale_bar(length: float | None = None, label: str | None = None):
145
145
 
146
146
  def layer(canvas, tree, layout, style):
147
147
  width, height = canvas.size
148
- m = style.margin
149
148
  span = layout.xlim[1] - layout.xlim[0]
150
149
  L = length if length is not None else _round_nice(span / 5 or 1.0)
151
150
  px_len = abs(canvas.px(L) - canvas.px(0.0))
152
- x1, y = width - m, height - m * 0.5
151
+ x1, y = width - style.margin_at("right"), height - style.margin_at("bottom") * 0.5
153
152
  x0 = x1 - px_len
154
153
  canvas.raw_line(x0, y, x1, y, "#333333", 1.6)
155
154
  canvas.raw_text((x0 + x1) / 2, y - 8, label or f"{L:.2g}",
@@ -173,18 +172,23 @@ def _round_ticks(span: float, target: int) -> list[float]:
173
172
  """About ``target`` tick positions from 0 to at most ``span``, at multiples of a
174
173
  round step (1, 2, 2.5 or 5 times a power of ten). The last tick may stop short
175
174
  of the axis end; a round number short of the edge beats an exact ugly one."""
176
- import math
177
175
  if span <= 0:
178
176
  return [0.0]
177
+ step = _round_step(span, target)
178
+ n = int(span / step + 1e-9)
179
+ return [round(i * step, 10) for i in range(n + 1)]
180
+
181
+
182
+ def _round_step(span: float, target: int) -> float:
183
+ """The round step (1, 2, 2.5 or 5 times a power of ten) that cuts ``span`` into at most
184
+ ``target - 1`` intervals. ``span`` must be positive."""
185
+ import math
179
186
  raw = span / max(target - 1, 1)
180
- mag = 10 ** math.floor(math.log10(raw))
181
- step = 10 * mag
187
+ mag = 10.0 ** math.floor(math.log10(raw))
182
188
  for mult in (1, 2, 2.5, 5, 10):
183
189
  if span / (mult * mag) <= target - 1 + 1e-9:
184
- step = mult * mag
185
- break
186
- n = int(span / step + 1e-9)
187
- return [round(i * step, 10) for i in range(n + 1)]
190
+ return mult * mag
191
+ return 10 * mag
188
192
 
189
193
 
190
194
  def time_axis(label: str = "Time", *, ticks: int = 5, tick_size: float | None = None,
@@ -192,30 +196,37 @@ def time_axis(label: str = "Time", *, ticks: int = 5, tick_size: float | None =
192
196
  """A horizontal scale along the bottom, in the layout's distance units (0 at the origin).
193
197
  Rectangular only (distance maps to x); use ``scale_bar`` for radial/unrooted. ``tick_size`` /
194
198
  ``label_size`` set the tick-number and axis-label font sizes (default: the style's font size);
195
- the vertical spacing follows the font, so give the figure enough bottom ``margin`` for big text.
199
+ the vertical spacing follows the font, so give the figure enough bottom margin for big text.
196
200
  ``bold`` sets the label weight (default: bold only when a ``label_size`` is given)."""
197
201
 
198
202
  def layer(canvas, tree, layout, style):
199
203
  if layout.kind != "rectangular":
200
204
  return
201
205
  _, height = canvas.size
202
- m = style.margin
203
- x0, x1 = 0.0, layout.xlim[1]
204
206
  ts = tick_size if tick_size is not None else style.font_size * 0.85
205
207
  ls = label_size if label_size is not None else style.font_size
206
- y = height - m + 14 # just below the tree area, inside the bottom margin
207
- canvas.raw_line(canvas.px(x0), y, canvas.px(x1), y, "#333333", 1.2)
208
- # ticks at round numbers (a 1 / 2 / 2.5 / 5 step), not at even fractions of the
209
- # height: dividing a height of 3.96 into quarters gave "0, 0.99, 2, 3, 4",
210
- # where the "2" was really 1.98 — ugly and, worse, slightly wrong
211
- for t in _round_ticks(x1 - x0, ticks):
212
- tx = canvas.px(x0 + t)
213
- canvas.raw_line(tx, y, tx, y + 5, "#333333", 1.2)
214
- canvas.raw_text(tx, y + ts + 3, f"{t:g}", anchor="middle", size=ts)
215
- if label:
216
- mid = (canvas.px(x0) + canvas.px(x1)) / 2
217
- is_bold = (label_size is not None) if bold is None else bold
218
- weight = "bold" if is_bold else "normal"
219
- canvas.raw_text(mid, y + ts + ls + 4, label, anchor="middle", size=ls, weight=weight)
208
+ is_bold = (label_size is not None) if bold is None else bold
209
+ y = height - style.margin_at("bottom") + 14 # just below the tree area, inside the bottom margin
210
+ draw_time_axis(canvas, canvas.px, layout.xlim[1], y, label, ticks=ticks, tick_size=ts,
211
+ label_size=ls, weight="bold" if is_bold else "normal")
220
212
 
221
213
  return layer
214
+
215
+
216
+ def draw_time_axis(canvas, px, x_end: float, y: float, label: str | None, *, ticks: int = 5,
217
+ tick_size: float, label_size: float, weight: str = "normal") -> None:
218
+ """Draw a time axis from 0 to ``x_end`` at pixel height ``y``, with ``px`` mapping a time to a
219
+ pixel x. Shared by :func:`time_axis` and :func:`~phylustrator.compose.below`, so an axis under a
220
+ panel is the same axis a tree draws under itself."""
221
+ canvas.raw_line(px(0.0), y, px(x_end), y, "#333333", 1.2)
222
+ # ticks at round numbers (a 1 / 2 / 2.5 / 5 step), not at even fractions of the
223
+ # height: dividing a height of 3.96 into quarters gave "0, 0.99, 2, 3, 4",
224
+ # where the "2" was really 1.98 — ugly and, worse, slightly wrong
225
+ for t in _round_ticks(x_end, ticks):
226
+ tx = px(t)
227
+ canvas.raw_line(tx, y, tx, y + 5, "#333333", 1.2)
228
+ canvas.raw_text(tx, y + tick_size + 3, f"{t:g}", anchor="middle", size=tick_size)
229
+ if label:
230
+ mid = (px(0.0) + px(x_end)) / 2
231
+ canvas.raw_text(mid, y + tick_size + label_size + 4, label, anchor="middle", size=label_size,
232
+ weight=weight)
@@ -0,0 +1,109 @@
1
+ """Panels that line up with a tree's **x** axis, drawn under a rectangular tree by
2
+ :func:`~phylustrator.compose.below`.
3
+
4
+ A row panel (``genomes.heatmap``, ``genomes.bars``) is handed one pixel ``y`` per tip and sits beside
5
+ the tree. These go the other way: a panel is handed the tree's :class:`~phylustrator.trees.Geometry`
6
+ and a pixel band ``[top, bottom]``, and places each value at its node's pixel x — so a quantity
7
+ measured at a node sits directly under that node.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+
14
+ from .layers.guides import _round_step, _text_width
15
+
16
+
17
+ class NodePoints:
18
+ """One point per named node, at the node's x and at a height set by its value.
19
+
20
+ ``values`` is ``{node name: number}``. Internal nodes count, so they need names (a ZOMBI2 tree
21
+ names every node). A name the tree does not have raises an error rather than dropping the point:
22
+ a typo would otherwise read as a missing measurement.
23
+
24
+ ``colors`` tints points per node, else the flat ``color``. ``line`` joins the points left to
25
+ right. ``zero`` draws a light line at 0 when the range crosses it — the reference a difference is
26
+ read against. ``vmin`` / ``vmax`` fix the y range; by default it is the values' range, widened
27
+ to round numbers. ``label`` names the y axis.
28
+ """
29
+
30
+ def __init__(self, values: dict, *, color: str = "#3a6ea5", colors: dict | None = None,
31
+ shape: str = "circle", size: float = 4.5, line: bool = False,
32
+ line_color: str | None = None, zero: bool = True, vmin: float | None = None,
33
+ vmax: float | None = None, label: str = "", ticks: int = 5) -> None:
34
+ if not values:
35
+ raise ValueError("node_points() needs at least one value")
36
+ self.values = {str(k): float(v) for k, v in values.items()}
37
+ self.color = color
38
+ self.colors = {str(k): v for k, v in colors.items()} if colors else {}
39
+ self.shape = shape
40
+ self.size = size
41
+ self.line = line
42
+ self.line_color = line_color
43
+ self.zero = zero
44
+ self.vmin = vmin
45
+ self.vmax = vmax
46
+ self.label = label
47
+ self.ticks = ticks
48
+
49
+ def _range(self) -> tuple[float, float, float]:
50
+ """The y range and its tick step. An end the caller did not fix is pushed out to the next
51
+ round tick, so the axis starts and stops on a labelled number."""
52
+ lo = min(self.values.values()) if self.vmin is None else self.vmin
53
+ hi = max(self.values.values()) if self.vmax is None else self.vmax
54
+ if hi <= lo: # one value, or all equal: give it a band to sit in
55
+ lo, hi = lo - 1.0, hi + 1.0
56
+ step = _round_step(hi - lo, self.ticks)
57
+ if self.vmin is None:
58
+ lo = math.floor(lo / step + 1e-9) * step
59
+ if self.vmax is None:
60
+ hi = math.ceil(hi / step - 1e-9) * step
61
+ return lo, hi, step
62
+
63
+ def draw_below(self, canvas, geometry, top: float, bottom: float, style) -> None:
64
+ at: dict[str, float] = {}
65
+ for node in geometry.nodes:
66
+ if node.name:
67
+ at.setdefault(node.name, node.x) # the first in preorder, as Tree.find
68
+ missing = sorted(set(self.values) - set(at))
69
+ if missing:
70
+ shown = ", ".join(missing[:5]) + (", …" if len(missing) > 5 else "")
71
+ raise ValueError(f"node_points: {len(missing)} name(s) not in the tree: {shown}")
72
+
73
+ lo, hi, step = self._range()
74
+
75
+ def py(v: float) -> float:
76
+ return bottom - (v - lo) / (hi - lo) * (bottom - top)
77
+
78
+ x0, x1 = geometry.x_pixels
79
+ ts, ls = style.font_size * 0.85, style.font_size
80
+ # the y axis stands a little left of time 0, so a point at the crown is not drawn on it
81
+ ax = x0 - 10
82
+ canvas.raw_line(ax, top, ax, bottom, "#333333", 1.2)
83
+ widest = 0.0
84
+ k = math.ceil(lo / step - 1e-9)
85
+ while k * step <= hi + step * 1e-9:
86
+ v = round(k * step, 10) + 0.0 # + 0.0 turns a -0.0 into 0
87
+ text = f"{v:g}"
88
+ canvas.raw_line(ax - 5, py(v), ax, py(v), "#333333", 1.2)
89
+ canvas.raw_text(ax - 8, py(v), text, anchor="end", size=ts)
90
+ widest = max(widest, _text_width(text, ts))
91
+ k += 1
92
+ if self.label:
93
+ canvas.raw_text(ax - 8 - widest - ls * 0.8, (top + bottom) / 2, self.label,
94
+ anchor="middle", size=ls, rotate=-90)
95
+ if self.zero and lo < 0.0 < hi:
96
+ canvas.raw_line(x0, py(0.0), x1, py(0.0), "#b8bec3", 1.0)
97
+
98
+ points = sorted((at[name], py(v), name) for name, v in self.values.items())
99
+ if self.line:
100
+ for (xa, ya, _), (xb, yb, _) in zip(points, points[1:]):
101
+ canvas.raw_line(xa, ya, xb, yb, self.line_color or self.color, 1.4)
102
+ for x, y, name in points:
103
+ canvas.raw_marker(x, y, self.shape, self.colors.get(name, self.color), self.size)
104
+
105
+
106
+ def node_points(values: dict, **kw) -> NodePoints:
107
+ """A panel under a tree: one point per named node, placed at the node's time. Pass it to
108
+ :func:`~phylustrator.compose.below`; see :class:`NodePoints` for the options."""
109
+ return NodePoints(values, **kw)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.2.17
3
+ Version: 0.3.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
@@ -75,6 +75,8 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
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
77
  `highlight_clade`.
78
+ - **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
79
+ shares the tree's time axis, so a value measured at a node sits directly under that node.
78
80
 
79
81
  ## Genomes
80
82
 
@@ -32,6 +32,7 @@ src/phylustrator/trees/__init__.py
32
32
  src/phylustrator/trees/figure.py
33
33
  src/phylustrator/trees/io.py
34
34
  src/phylustrator/trees/layout.py
35
+ src/phylustrator/trees/panels.py
35
36
  src/phylustrator/trees/skeleton.py
36
37
  src/phylustrator/trees/tree.py
37
38
  src/phylustrator/trees/layers/__init__.py
@@ -41,6 +42,7 @@ src/phylustrator/trees/layers/events.py
41
42
  src/phylustrator/trees/layers/guides.py
42
43
  src/phylustrator/trees/layers/labels.py
43
44
  src/phylustrator/trees/layers/tracks.py
45
+ tests/test_below.py
44
46
  tests/test_cli.py
45
47
  tests/test_figure.py
46
48
  tests/test_genomes.py
@@ -0,0 +1,137 @@
1
+ """A panel under a rectangular tree, on the tree's time axis: ``below``, ``node_points``, the node
2
+ positions in ``Figure.geometry`` and the per-side margins that make room for them (issue #7)."""
3
+
4
+ import re
5
+ from dataclasses import replace
6
+
7
+ import pytest
8
+
9
+ from phylustrator import Style, below
10
+ from phylustrator.genomes import bars
11
+ from phylustrator.trees import loads, node_points, plot, tip_labels
12
+
13
+ # R's stem is 0.5, so under the default stem-aware layout: R 0.5, E 1.5, H 2.0, C 2.5, A 3.5
14
+ NEWICK = "(((A:1,B:1)C:1,D:2)E:1,(F:1.5,G:1.5)H:1.5)R:0.5;"
15
+
16
+
17
+ def _circles(svg):
18
+ return [(float(x), float(y)) for x, y in re.findall(r'<circle cx="([-\d.]+)" cy="([-\d.]+)"', svg)]
19
+
20
+
21
+ def _texts(svg):
22
+ return re.findall(r"<text [^>]*>([^<]*)</text>", svg)
23
+
24
+
25
+ # --- geometry -------------------------------------------------------------------------------------
26
+
27
+ def test_geometry_lists_every_node_and_maps_time_to_pixels():
28
+ geom = plot(loads(NEWICK)).geometry()
29
+ by_name = {n.name: n for n in geom.nodes}
30
+ assert len(geom.nodes) == 9
31
+ assert {n.name for n in geom.nodes if not n.is_leaf} == {"R", "E", "C", "H"}
32
+ for tip in geom.tips: # a tip is the same point either way
33
+ assert (by_name[tip.name].x, by_name[tip.name].y) == pytest.approx((tip.x, tip.y))
34
+ assert geom.px(2.5) == pytest.approx(by_name["C"].x)
35
+ assert geom.px(geom.xlim[1]) == pytest.approx(by_name["A"].x)
36
+ assert geom.x_pixels[0] == pytest.approx(50.0) # time 0 sits on the left margin
37
+
38
+
39
+ # --- margins --------------------------------------------------------------------------------------
40
+
41
+ def test_side_margins_default_to_margin():
42
+ tree = loads(NEWICK)
43
+ sides = dict(margin_left=30, margin_right=30, margin_top=30, margin_bottom=30)
44
+ assert (plot(tree, style=Style(margin=30)).as_svg()
45
+ == plot(tree, style=Style(margin=30, **sides)).as_svg())
46
+
47
+
48
+ def test_a_side_left_unset_follows_margin_after_replace():
49
+ """Resolving the sides once, at construction, would freeze them at the old ``margin``."""
50
+ style = replace(Style(margin=50, margin_left=90), margin=10)
51
+ assert style.margin_at("left") == 90
52
+ assert style.margin_at("bottom") == 10
53
+ with pytest.raises(ValueError):
54
+ style.margin_at("middle")
55
+
56
+
57
+ def test_one_side_margin_moves_only_that_side():
58
+ tree = loads(NEWICK)
59
+ base = Style(width=400, height=300, margin=20)
60
+ plain = plot(tree, style=base).geometry()
61
+ wide = plot(tree, style=replace(base, margin_left=80)).geometry()
62
+ assert wide.x_pixels[0] == pytest.approx(80.0)
63
+ assert wide.x_pixels[1] == pytest.approx(plain.x_pixels[1])
64
+ assert [t.y for t in wide.tips] == pytest.approx([t.y for t in plain.tips])
65
+
66
+
67
+ # --- below ----------------------------------------------------------------------------------------
68
+
69
+ def test_below_puts_each_point_under_its_node():
70
+ """The point of the issue: a value measured at a node is drawn at that node's pixel x."""
71
+ fig = plot(loads(NEWICK), style=Style(margin_left=70)) + tip_labels()
72
+ values = {"R": -1.0, "E": 2.0, "H": 3.0, "C": 0.5, "A": 1.0}
73
+ svg = below(fig, node_points(values), axis=None).as_svg()
74
+ geom = fig.geometry()
75
+ x_of = {n.name: n.x for n in geom.nodes}
76
+ circles = sorted(_circles(svg))
77
+ assert [x for x, _ in circles] == pytest.approx(sorted(x_of[name] for name in values))
78
+ lowest_tip = max(t.y for t in geom.tips)
79
+ assert all(y > lowest_tip for _, y in circles)
80
+ # higher value, higher on the page: sorted by x the nodes are R, E, H, C, A
81
+ ys = dict(zip(["R", "E", "H", "C", "A"], (y for _, y in circles)))
82
+ assert ys["H"] < ys["E"] < ys["A"] < ys["C"] < ys["R"]
83
+
84
+
85
+ def test_below_leaves_the_tree_where_it_was():
86
+ fig = plot(loads(NEWICK)) + tip_labels()
87
+ alone = fig.as_svg()
88
+ composite = below(fig, node_points({"C": 1.0})).as_svg()
89
+ branches = re.findall(r"<path [^>]*/>", alone)
90
+ labels = re.findall(r"<text [^>]*>[^<]*</text>", alone)
91
+ assert branches and labels
92
+ assert all(element in composite for element in branches + labels)
93
+
94
+
95
+ def test_below_draws_the_time_axis_under_the_panel_and_on_the_page():
96
+ """A small bottom margin once cut the axis label in half at the page edge."""
97
+ fig = plot(loads(NEWICK), style=Style(margin=20))
98
+ svg = below(fig, node_points({"C": 1.0, "H": 2.0}), axis="Time (My)").as_svg()
99
+ assert "Time (My)" in _texts(svg)
100
+ axis_y = float(re.search(r'<text x="[\d.]+" y="([\d.]+)"[^>]*>Time \(My\)</text>', svg).group(1))
101
+ page_height = float(re.search(r'<svg [^>]*height="([\d.]+)"', svg).group(1))
102
+ assert all(axis_y > y for _, y in _circles(svg))
103
+ assert axis_y + fig.style.font_size / 2 < page_height
104
+ assert "Time (My)" not in below(fig, node_points({"C": 1.0}), axis=None).as_svg()
105
+
106
+
107
+ def test_below_refuses_a_tree_without_a_time_axis_and_a_row_panel():
108
+ tree = loads(NEWICK)
109
+ with pytest.raises(ValueError, match="rectangular"):
110
+ below(plot(tree, layout="radial"), node_points({"C": 1.0}))
111
+ with pytest.raises(TypeError, match="beside"):
112
+ below(plot(tree), bars({"A": 1.0}))
113
+
114
+
115
+ # --- node_points ----------------------------------------------------------------------------------
116
+
117
+ def test_node_points_names_the_nodes_it_cannot_find():
118
+ with pytest.raises(ValueError, match="Cx"):
119
+ below(plot(loads(NEWICK)), node_points({"Cx": 1.0, "C": 2.0}))
120
+
121
+
122
+ def test_node_points_ticks_stop_on_round_numbers():
123
+ svg = below(plot(loads(NEWICK)), node_points({"C": -1.3, "H": 2.7}), axis=None).as_svg()
124
+ assert {"-2", "-1", "0", "1", "2", "3"} <= set(_texts(svg))
125
+
126
+
127
+ def test_node_points_zero_line_only_when_the_range_crosses_zero():
128
+ tree = plot(loads(NEWICK))
129
+ assert "#b8bec3" in below(tree, node_points({"C": -1.0, "H": 2.0})).as_svg()
130
+ assert "#b8bec3" not in below(tree, node_points({"C": 1.0, "H": 2.0})).as_svg()
131
+ assert "#b8bec3" not in below(tree, node_points({"C": -1.0, "H": 2.0}, zero=False)).as_svg()
132
+
133
+
134
+ def test_node_points_line_joins_the_points_left_to_right():
135
+ values = {"R": 0.0, "E": 1.0, "H": 3.0, "C": 2.0}
136
+ svg = below(plot(loads(NEWICK)), node_points(values, line=True, line_color="#123456")).as_svg()
137
+ assert svg.count('stroke="#123456"') == 3
@@ -1,66 +0,0 @@
1
- """Composition — put a Genustrator panel beside a Phylustrator tree, rows lined up with the tips.
2
-
3
- ``beside(tree, panel)`` takes a **Phylustrator** figure (the phylogeny — Phylustrator draws it, we do
4
- not redraw it) and a panel (``heatmap`` / ``alignment``). It asks the tree for its tip pixel positions
5
- (``Figure.geometry``), renders the tree into the left column, and draws the panel to the right with
6
- each row at its tip's ``y`` — so a genome's row sits exactly on its leaf.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from .render import Canvas
12
- from .style import Style
13
-
14
-
15
- class Composite:
16
- """The rendered composite; save like any figure."""
17
-
18
- def __init__(self, canvas: Canvas) -> None:
19
- self._canvas = canvas
20
-
21
- def as_svg(self) -> str:
22
- return self._canvas.as_svg()
23
-
24
- def save(self, path):
25
- return self._canvas.save(path)
26
-
27
-
28
- def beside(tree, panel, *, width: float = 1100.0, height: float | None = None,
29
- tree_fraction: float = 0.4, gap: float = 18.0, pad: float = 34.0,
30
- footer: float = 0.0, background: str = "white") -> Composite:
31
- """Render Phylustrator ``tree`` on the left and ``panel`` on the right, rows aligned to the tips.
32
-
33
- ``tree_fraction`` is the share of the width given to the tree column (the rest, minus ``gap`` and
34
- ``pad``, holds the panel). ``footer`` reserves blank height below the rows (for a panel's colour
35
- key). Rows are matched to tips by label and drawn in the tree's tip order. Needs ``cairosvg``."""
36
- try:
37
- import cairosvg
38
- except ImportError as exc: # pragma: no cover
39
- raise RuntimeError("genustrator.beside needs cairosvg (pip install genustrator[export]) "
40
- "to place the tree into the composite") from exc
41
-
42
- n_tips = len(tree.tree.leaves)
43
- # per-tip row height eases from ~44px (few tips) down to ~24px (many), so 25-40 rows stay sane
44
- row_px = max(24.0, 46.0 - 0.55 * n_tips)
45
- H = height if height is not None else max(260.0, 70.0 + row_px * n_tips) + footer
46
- tree_w = round(width * tree_fraction)
47
- tree_h = H - footer # tips fill the area above the footer
48
-
49
- sized = tree.with_size(tree_w, tree_h)
50
- geom = sized.geometry()
51
- png = cairosvg.svg2png(bytestring=sized.as_svg().encode(),
52
- output_width=int(tree_w * 2), output_height=int(tree_h * 2))
53
-
54
- canvas = Canvas(Style(width=width, height=H, margin=0, background=background), (0.0, 1.0), (0.0, 1.0))
55
- canvas.embed_png(png, 0, 0, tree_w, tree_h)
56
-
57
- # A panel that wants **every** tip row says so, and gets them all — including tips it has no
58
- # value for. `Bars` does: its axis sits under the last row it is handed, and filtering the rows
59
- # down to the ones it has values for put that axis under the last *valued* tip instead of under
60
- # the tree, so a tree with extinct tips below the last bar drew its two axes at different heights.
61
- wanted = set(panel.rows)
62
- rows = [(t.name, t.y) for t in geom.tips
63
- if getattr(panel, "all_rows", False) or t.name in wanted]
64
- if rows:
65
- panel.draw(canvas, tree_w + gap, width - pad, rows, canvas.style)
66
- return Composite(canvas)
File without changes
File without changes