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.
- {phylustrator-0.2.16/src/phylustrator.egg-info → phylustrator-0.3.0}/PKG-INFO +3 -1
- {phylustrator-0.2.16 → phylustrator-0.3.0}/README.md +2 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/pyproject.toml +1 -1
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/__init__.py +4 -3
- phylustrator-0.3.0/src/phylustrator/compose.py +121 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/figure.py +3 -4
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/guides.py +1 -2
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/render.py +24 -9
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/style.py +18 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/__init__.py +4 -2
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/figure.py +37 -7
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/coloring.py +47 -3
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/events.py +46 -7
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/guides.py +64 -45
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/tracks.py +4 -3
- phylustrator-0.3.0/src/phylustrator/trees/panels.py +109 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0/src/phylustrator.egg-info}/PKG-INFO +3 -1
- {phylustrator-0.2.16 → 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.16/src/phylustrator/compose.py +0 -66
- {phylustrator-0.2.16 → phylustrator-0.3.0}/LICENSE +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/setup.cfg +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/__main__.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/cli.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/color.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/__init__.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/genome.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/io.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/genes.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/layout.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/matrix.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/panels.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/genomes/track.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/io.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/__init__.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/clades.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layers/labels.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/layout.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/skeleton.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/trees/tree.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator/zombi.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/requires.txt +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/src/phylustrator.egg-info/top_level.txt +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_cli.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_figure.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_genomes.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_io.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_layout.py +0 -0
- {phylustrator-0.2.16 → phylustrator-0.3.0}/tests/test_tree.py +0 -0
- {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.
|
|
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 {}
|
|
@@ -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
|
|
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
|
|
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)
|
|
@@ -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
|
|
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
|
|
78
|
-
raise ValueError("color_history
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = (
|
|
87
|
-
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)
|
|
88
127
|
if title:
|
|
89
128
|
canvas.raw_text(x, y, title, anchor="start", weight="bold", size=fs)
|
|
90
129
|
y += fs * 1.8
|