phylustrator 0.2.16__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.16/src/phylustrator.egg-info → phylustrator-0.3.0}/PKG-INFO +3 -1
  2. {phylustrator-0.2.16 → phylustrator-0.3.0}/README.md +2 -0
  3. {phylustrator-0.2.16 → phylustrator-0.3.0}/pyproject.toml +1 -1
  4. {phylustrator-0.2.16 → 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.16 → phylustrator-0.3.0}/src/phylustrator/genomes/figure.py +3 -4
  7. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/guides.py +1 -2
  8. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/render.py +24 -9
  9. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/style.py +18 -0
  10. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/__init__.py +4 -2
  11. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/figure.py +37 -7
  12. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/coloring.py +47 -3
  13. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/events.py +46 -7
  14. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/guides.py +64 -45
  15. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/tracks.py +4 -3
  16. phylustrator-0.3.0/src/phylustrator/trees/panels.py +109 -0
  17. {phylustrator-0.2.16 → phylustrator-0.3.0/src/phylustrator.egg-info}/PKG-INFO +3 -1
  18. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/SOURCES.txt +2 -0
  19. phylustrator-0.3.0/tests/test_below.py +137 -0
  20. phylustrator-0.2.16/src/phylustrator/compose.py +0 -66
  21. {phylustrator-0.2.16 → phylustrator-0.3.0}/LICENSE +0 -0
  22. {phylustrator-0.2.16 → phylustrator-0.3.0}/setup.cfg +0 -0
  23. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/__main__.py +0 -0
  24. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/cli.py +0 -0
  25. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/color.py +0 -0
  26. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/__init__.py +0 -0
  27. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/genome.py +0 -0
  28. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/io.py +0 -0
  29. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
  30. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/genes.py +0 -0
  31. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
  32. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
  33. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layout.py +0 -0
  34. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/matrix.py +0 -0
  35. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/panels.py +0 -0
  36. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/track.py +0 -0
  37. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/io.py +0 -0
  38. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/__init__.py +0 -0
  39. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/clades.py +0 -0
  40. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/labels.py +0 -0
  41. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layout.py +0 -0
  42. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/skeleton.py +0 -0
  43. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/tree.py +0 -0
  44. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/zombi.py +0 -0
  45. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
  46. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
  47. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/requires.txt +0 -0
  48. {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/top_level.txt +0 -0
  49. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_cli.py +0 -0
  50. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_figure.py +0 -0
  51. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_genomes.py +0 -0
  52. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_io.py +0 -0
  53. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_layout.py +0 -0
  54. {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_tree.py +0 -0
  55. {phylustrator-0.2.16 → 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.16
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.16"
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 {}
@@ -179,7 +187,8 @@ class Canvas:
179
187
  self._d.append(draw.Image(x, y, w, h, data=data, embed=True, mime_type="image/png"))
180
188
 
181
189
  def raw_marker(self, cx, cy, shape: str, color: str, size: float, *,
182
- stroke: str = "#ffffff", stroke_width: float = 0.8) -> None:
190
+ stroke: str = "#ffffff", stroke_width: float = 0.8,
191
+ angle: float = 0.0) -> None:
183
192
  """A small glyph at pixel ``(cx, cy)``: ``circle`` / ``square`` / ``triangle`` /
184
193
  ``triangle_right`` (points along the time axis, for a state transition) / ``diamond``
185
194
  (filled), ``ring`` (open), or ``cross`` (an ✕, for a loss).
@@ -204,6 +213,12 @@ class Canvas:
204
213
  (cx - r * 0.85, cy + r)],
205
214
  "diamond": [(cx, cy - r), (cx + r, cy), (cx, cy + r),
206
215
  (cx - r, cy)]}[shape]
216
+ if angle:
217
+ # rotate the glyph around its centre, so "along the time axis" can follow a
218
+ # radial branch's direction rather than always pointing right
219
+ ca, sa = math.cos(angle), math.sin(angle)
220
+ pts = [(cx + (x - cx) * ca - (y - cy) * sa,
221
+ cy + (x - cx) * sa + (y - cy) * ca) for x, y in pts]
207
222
  self._d.append(draw.Lines(*[c for p in pts for c in p], fill=color,
208
223
  stroke=stroke, stroke_width=stroke_width, close=True))
209
224
  elif shape == "ring":
@@ -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)
@@ -60,7 +60,7 @@ def color_history(history, *, palette: dict | None = None, cmap: str = "viridis"
60
60
  ``(state, duration)`` running from the branch's start to its end. Use this (not
61
61
  :func:`color_branches`) for a value that changes *along* a branch: the branch is a mosaic, not
62
62
  one colour. ``dashed`` is an optional set of node names to draw dashed (e.g. extinct lineages).
63
- Rectangular layout only. ``history``: ``{node name: [(state, dur), …]}``.
63
+ Rectangular and radial layouts. ``history``: ``{node name: [(state, dur), …]}``.
64
64
 
65
65
  Dispatches on the states the same way :func:`color_branches` dispatches on its values:
66
66
  **labels** get a categorical palette (and record it, so ``legend`` can draw), **numbers** get a
@@ -74,13 +74,16 @@ def color_history(history, *, palette: dict | None = None, cmap: str = "viridis"
74
74
  colors, scale = map_values({s: s for s in states}, cmap=cmap, palette=palette, limits=limits)
75
75
 
76
76
  def layer(canvas, tree, layout, style):
77
- if layout.kind != "rectangular":
78
- raise ValueError("color_history needs the rectangular layout (segments run along x)")
77
+ if layout.kind not in ("rectangular", "radial"):
78
+ raise ValueError("color_history supports the rectangular and radial layouts")
79
79
  marks = _dashed_or_figure(dashed, canvas)
80
80
  w = width or style.branch_width
81
81
  if scale is not None:
82
82
  canvas.scale = scale
83
83
  base = default or style.branch_color
84
+ if layout.kind == "radial":
85
+ _history_radial(canvas, tree, layout, history, colors, base, w, marks)
86
+ return
84
87
  for node in tree.walk():
85
88
  y = layout.y(node)
86
89
  x_end = layout.x(node)
@@ -108,6 +111,47 @@ def color_history(history, *, palette: dict | None = None, cmap: str = "viridis"
108
111
  return layer
109
112
 
110
113
 
114
+ def _history_radial(canvas, tree, layout, history, colors, base, w, marks) -> None:
115
+ """The radial half of :func:`color_history`: each branch is a mosaic running OUT along the
116
+ node's angle, from the parent's radius to the node's, and the speciation connector is an arc
117
+ at the node's radius in the branch's end state — mirrors ``skeleton._radial``."""
118
+ import math
119
+
120
+ from ..skeleton import _arc
121
+
122
+ ang = layout.angle
123
+
124
+ def radius(node):
125
+ return math.hypot(layout.x(node), layout.y(node))
126
+
127
+ for node in tree.walk():
128
+ a = ang[node]
129
+ r_end = radius(node)
130
+ r_start = max(0.0, r_end - layout.root_branch) if node.is_root else radius(node.parent)
131
+ d = node.name in marks
132
+ ca, sa = math.cos(a), math.sin(a)
133
+ segs = history.get(node.name)
134
+ end_state = None
135
+ if segs:
136
+ total = sum(dur for _, dur in segs) or 1.0
137
+ span = r_end - r_start
138
+ rr = r_start
139
+ for state, dur in segs:
140
+ r1 = rr + span * dur / total
141
+ if r1 != rr: # two changes at the same instant leave a zero-length segment
142
+ canvas.line(rr * ca, rr * sa, r1 * ca, r1 * sa,
143
+ colors.get(state, base), w, dash=d)
144
+ rr = r1
145
+ end_state = segs[-1][0]
146
+ else:
147
+ canvas.line(r_start * ca, r_start * sa, r_end * ca, r_end * sa, base, w, dash=d)
148
+ if not node.is_leaf and r_end > 1e-9: # angular connectors in the node's end state
149
+ cc = colors.get(end_state, base)
150
+ for c in node.children:
151
+ _arc(canvas, r_end, min(a, ang[c]), max(a, ang[c]), cc, w,
152
+ dash=(c.name in marks))
153
+
154
+
111
155
  def color_lanes(lanes, *, width=None, gap: float = 1.0, connectors: bool = True,
112
156
  joint: str | None = None, default: str | None = None, dashed=None):
113
157
  """Paint each branch as several **parallel lanes** — one per trait — so more than one discrete
@@ -45,7 +45,24 @@ def branch_events(events, *, styles: dict | None = None, size: float = 5.5,
45
45
  styles = {**DEFAULT_EVENT_STYLES, **(styles or {})}
46
46
 
47
47
  def layer(canvas, tree, layout, style):
48
+ import math
49
+
48
50
  by_name = {n.name: n for n in tree.walk() if n.name}
51
+ radial = layout.kind == "radial"
52
+ # events carry distance on the stem-inclusive scale (the rectangular default); the radial
53
+ # layout drops the stem (its radius starts at the crown), so shift by the stem's length
54
+ stem_off = float(tree.root.length or 0.0) if radial else 0.0
55
+
56
+ def rad(node):
57
+ return math.hypot(layout.x(node), layout.y(node))
58
+
59
+ def place(node, t):
60
+ """An event at distance ``t`` on ``node``'s branch, in layout coordinates."""
61
+ if not radial:
62
+ return t, layout.y(node)
63
+ a = layout.angle[node] # the distance is the radius
64
+ return t * math.cos(a), t * math.sin(a)
65
+
49
66
  used: dict[str, tuple] = {}
50
67
  for raw in events:
51
68
  ev = _unpack(raw)
@@ -56,17 +73,40 @@ def branch_events(events, *, styles: dict | None = None, size: float = 5.5,
56
73
  continue
57
74
  # scale the arrow with `size` (as the point glyphs do) so the head reads as an arrow,
58
75
  # not a tick, on a large figure
59
- canvas.arrow(ev["x"], layout.y(donor), ev["x"], layout.y(recip), color,
76
+ dx, dy = place(donor, ev["x"] - stem_off)
77
+ rx, ry = place(recip, ev["x"] - stem_off)
78
+ canvas.arrow(dx, dy, rx, ry, color,
60
79
  width=max(1.8, size * 0.42), head=max(9.0, size * 2.4))
61
80
  else:
62
81
  node = by_name.get(ev.get("node"))
63
82
  if node is None:
64
83
  continue
65
- x = ev["x"]
84
+ x = ev["x"] - stem_off # onto the layout's own distance scale
66
85
  if clamp and node.parent is not None:
67
- lo, hi = sorted((layout.x(node.parent), layout.x(node)))
86
+ if radial:
87
+ lo, hi = sorted((rad(node.parent), rad(node)))
88
+ else:
89
+ lo, hi = sorted((layout.x(node.parent), layout.x(node)))
68
90
  x = min(max(x, lo), hi)
69
- canvas.marker(x, layout.y(node), glyph, color, size)
91
+ mx, my = place(node, x)
92
+ if radial:
93
+ # the glyph's "forward" must follow the branch: rotate by the branch
94
+ # direction in PIXEL space (py may flip y, so measure it there)
95
+ ax, ay = place(node, max(x - 1e-6, 0.0))
96
+ px, py = canvas.px(mx), canvas.py(my)
97
+ ang = math.atan2(py - canvas.py(ay), px - canvas.px(ax))
98
+ if glyph == "triangle_right":
99
+ # tip AT the event's instant, body trailing over the state it leaves:
100
+ # centred, the glyph swallows a short old-state segment and reads as a
101
+ # switch inside its own destination colour
102
+ px -= size * math.cos(ang)
103
+ py -= size * math.sin(ang)
104
+ # an ink outline, not white: the tip sits against a branch of its own
105
+ # colour, and without a silhouette the flared base reads as the point
106
+ canvas.raw_marker(px, py, glyph, color, size, angle=ang,
107
+ stroke="#1a1a1a", stroke_width=1.1)
108
+ else:
109
+ canvas.marker(mx, my, glyph, color, size)
70
110
  used[ev["kind"]] = (glyph, color)
71
111
  if legend and used:
72
112
  _draw_legend(canvas, style, used, legend_title, size, legend_loc, legend_size)
@@ -76,15 +116,14 @@ def branch_events(events, *, styles: dict | None = None, size: float = 5.5,
76
116
 
77
117
  def _draw_legend(canvas, style, used, title, marker, loc, fsize) -> None:
78
118
  width, height = canvas.size
79
- m = style.margin
80
119
  fs = fsize if fsize is not None else style.font_size
81
120
  ms = marker * (fs / style.font_size) # glyphs scale with the legend text
82
121
  row_h = fs * 1.7
83
122
  labels = ([title] if title else []) + list(used)
84
123
  box_w = ms * 2 + 14 + max(len(s) for s in labels) * fs * 0.62
85
124
  n_rows = len(used) + (1 if title else 0)
86
- x = (m + ms + 6) if "left" in loc else (width - m - box_w)
87
- 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)
88
127
  if title:
89
128
  canvas.raw_text(x, y, title, anchor="start", weight="bold", size=fs)
90
129
  y += fs * 1.8