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.
- {phylustrator-0.2.17/src/phylustrator.egg-info → phylustrator-0.3.0}/PKG-INFO +3 -1
- {phylustrator-0.2.17 → phylustrator-0.3.0}/README.md +2 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/pyproject.toml +1 -1
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/__init__.py +4 -3
- phylustrator-0.3.0/src/phylustrator/compose.py +121 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/figure.py +3 -4
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/guides.py +1 -2
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/render.py +16 -8
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/style.py +18 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/__init__.py +4 -2
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/figure.py +37 -7
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/events.py +2 -3
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/guides.py +46 -35
- phylustrator-0.3.0/src/phylustrator/trees/panels.py +109 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0/src/phylustrator.egg-info}/PKG-INFO +3 -1
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/SOURCES.txt +2 -0
- phylustrator-0.3.0/tests/test_below.py +137 -0
- phylustrator-0.2.17/src/phylustrator/compose.py +0 -66
- {phylustrator-0.2.17 → phylustrator-0.3.0}/LICENSE +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/setup.cfg +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/__main__.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/cli.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/color.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/__init__.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/genome.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/io.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/genes.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/layout.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/matrix.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/panels.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/genomes/track.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/io.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/__init__.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/clades.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/coloring.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/labels.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layers/tracks.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/layout.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/skeleton.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/trees/tree.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator/zombi.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/requires.txt +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/src/phylustrator.egg-info/top_level.txt +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_cli.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_figure.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_genomes.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_io.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_layout.py +0 -0
- {phylustrator-0.2.17 → phylustrator-0.3.0}/tests/test_tree.py +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
182
|
-
w = self.style.width -
|
|
183
|
-
h = self.style.height -
|
|
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 -
|
|
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.
|
|
34
|
-
self.
|
|
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 -
|
|
41
|
-
(style.height - self.
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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.
|
|
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
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
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 = (
|
|
127
|
-
y = (
|
|
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
|
-
|
|
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 =
|
|
47
|
+
x = left
|
|
47
48
|
if "bottom" in loc:
|
|
48
|
-
y = h -
|
|
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 =
|
|
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
|
|
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 =
|
|
133
|
-
y = (
|
|
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 -
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|