phylustrator 0.1.0__py3-none-any.whl
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/__init__.py +29 -0
- phylustrator/__main__.py +5 -0
- phylustrator/cli.py +85 -0
- phylustrator/color.py +92 -0
- phylustrator/compose.py +61 -0
- phylustrator/genomes/__init__.py +23 -0
- phylustrator/genomes/figure.py +107 -0
- phylustrator/genomes/genome.py +41 -0
- phylustrator/genomes/io.py +56 -0
- phylustrator/genomes/layers/__init__.py +8 -0
- phylustrator/genomes/layers/genes.py +35 -0
- phylustrator/genomes/layers/guides.py +78 -0
- phylustrator/genomes/layers/highlight.py +38 -0
- phylustrator/genomes/layers/synteny.py +56 -0
- phylustrator/genomes/layout.py +130 -0
- phylustrator/genomes/matrix.py +32 -0
- phylustrator/genomes/panels.py +208 -0
- phylustrator/genomes/track.py +59 -0
- phylustrator/render.py +229 -0
- phylustrator/style.py +29 -0
- phylustrator/trees/__init__.py +23 -0
- phylustrator/trees/figure.py +122 -0
- phylustrator/trees/io.py +148 -0
- phylustrator/trees/layers/__init__.py +30 -0
- phylustrator/trees/layers/clades.py +35 -0
- phylustrator/trees/layers/coloring.py +143 -0
- phylustrator/trees/layers/events.py +96 -0
- phylustrator/trees/layers/guides.py +149 -0
- phylustrator/trees/layers/labels.py +49 -0
- phylustrator/trees/layers/tracks.py +36 -0
- phylustrator/trees/layout.py +136 -0
- phylustrator/trees/skeleton.py +90 -0
- phylustrator/trees/tree.py +90 -0
- phylustrator/zombi.py +145 -0
- phylustrator-0.1.0.dist-info/METADATA +132 -0
- phylustrator-0.1.0.dist-info/RECORD +40 -0
- phylustrator-0.1.0.dist-info/WHEEL +5 -0
- phylustrator-0.1.0.dist-info/entry_points.txt +2 -0
- phylustrator-0.1.0.dist-info/licenses/LICENSE +21 -0
- phylustrator-0.1.0.dist-info/top_level.txt +1 -0
phylustrator/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Phylustrator — a lean, composable plotter for phylogenetics: **trees** and **genomes**.
|
|
2
|
+
|
|
3
|
+
Two domains, one grammar (``plot(x) + layer + …``), one shared drawing backend:
|
|
4
|
+
|
|
5
|
+
import phylustrator as ph
|
|
6
|
+
|
|
7
|
+
# trees
|
|
8
|
+
tree = ph.trees.loads("((A:1,B:1)C:2,D:3)R;")
|
|
9
|
+
(ph.trees.plot(tree) + ph.trees.color_branches(vals) + ph.trees.time_axis()).save("tree.pdf")
|
|
10
|
+
|
|
11
|
+
# genomes
|
|
12
|
+
G = ph.zombi.read_genomes("run")
|
|
13
|
+
(ph.genomes.plot(G["n12"], layout="circular") + ph.genomes.genes(by="family")).save("ring.png")
|
|
14
|
+
|
|
15
|
+
# bridge: a matrix beside a tree
|
|
16
|
+
ph.beside(ph.trees.plot(tree) + ph.trees.tip_labels(), ph.genomes.heatmap(ph.zombi.read_profiles("run")))
|
|
17
|
+
|
|
18
|
+
``ph.zombi`` is the only ZOMBI2-format-aware module; ``trees`` / ``genomes`` are general.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from . import genomes, trees, zombi
|
|
24
|
+
from .compose import Composite, beside
|
|
25
|
+
from .style import Style
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = ["trees", "genomes", "zombi", "beside", "Composite", "Style", "__version__"]
|
phylustrator/__main__.py
ADDED
phylustrator/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""``phyl`` — a one-shot tree viewer.
|
|
2
|
+
|
|
3
|
+
phyl tree.nwk # render to a temporary PDF and open it
|
|
4
|
+
phyl tree.nwk -o fig.svg # save instead (format from the extension)
|
|
5
|
+
phyl tree.nwk --radial --no-labels
|
|
6
|
+
|
|
7
|
+
Deliberately tiny: it reads a Newick file, draws it, and shows or saves it. Anything richer
|
|
8
|
+
(colouring, tracks, custom styles) lives in the Python API.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .trees import node_labels, plot, read, scale_bar, tip_labels, time_axis
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parser() -> argparse.ArgumentParser:
|
|
25
|
+
p = argparse.ArgumentParser(prog="phyl", description="Draw a phylogenetic tree from a Newick file.")
|
|
26
|
+
p.add_argument("tree", help="Newick tree file")
|
|
27
|
+
p.add_argument("-o", "--output", metavar="FILE",
|
|
28
|
+
help="save here (.svg/.pdf/.png); default: a temporary PDF, opened")
|
|
29
|
+
p.add_argument("--layout", choices=["rectangular", "radial", "unrooted"], default="rectangular")
|
|
30
|
+
p.add_argument("--radial", action="store_const", const="radial", dest="layout",
|
|
31
|
+
help="shortcut for --layout radial")
|
|
32
|
+
p.add_argument("--unrooted", action="store_const", const="unrooted", dest="layout",
|
|
33
|
+
help="shortcut for --layout unrooted")
|
|
34
|
+
p.add_argument("--no-labels", action="store_true", help="hide tip labels")
|
|
35
|
+
p.add_argument("--node-labels", action="store_true", help="also label internal nodes")
|
|
36
|
+
p.add_argument("--no-stem", action="store_true", help="start at the crown (hide the root stem)")
|
|
37
|
+
p.add_argument("--no-open", action="store_true", help="do not open the temporary file")
|
|
38
|
+
p.add_argument("-V", "--version", action="version", version=f"phyl {__version__}")
|
|
39
|
+
return p
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _open(path: Path) -> None:
|
|
43
|
+
try:
|
|
44
|
+
if sys.platform == "darwin":
|
|
45
|
+
subprocess.run(["open", str(path)], check=False)
|
|
46
|
+
elif sys.platform.startswith("win"):
|
|
47
|
+
os.startfile(str(path)) # type: ignore[attr-defined] # noqa: S606
|
|
48
|
+
else:
|
|
49
|
+
subprocess.run(["xdg-open", str(path)], check=False)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass # opening is a convenience; the path is printed regardless
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv: list[str] | None = None) -> int:
|
|
55
|
+
parser = _parser()
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
tree = read(args.tree)
|
|
60
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
61
|
+
parser.error(str(exc))
|
|
62
|
+
|
|
63
|
+
figure = plot(tree, layout=args.layout, stem=not args.no_stem)
|
|
64
|
+
if not args.no_labels:
|
|
65
|
+
figure = figure + tip_labels()
|
|
66
|
+
if args.node_labels:
|
|
67
|
+
figure = figure + node_labels()
|
|
68
|
+
figure = figure + (time_axis() if args.layout == "rectangular" else scale_bar())
|
|
69
|
+
|
|
70
|
+
if args.output:
|
|
71
|
+
out = figure.save(args.output)
|
|
72
|
+
print(out)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
# No -o: a genuinely throwaway file in the system temp dir.
|
|
76
|
+
tmp = Path(tempfile.mkdtemp(prefix="phyl_")) / "tree.pdf"
|
|
77
|
+
out = figure.save(tmp) # may return a .svg sibling if cairosvg is absent
|
|
78
|
+
print(out)
|
|
79
|
+
if not args.no_open:
|
|
80
|
+
_open(out)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
phylustrator/color.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Colour — colormaps for continuous values, palettes for categories, and normalisation.
|
|
2
|
+
|
|
3
|
+
Matplotlib-free: the viridis ramp is a small embedded lookup table sampled by interpolation, and the
|
|
4
|
+
categorical palette is Paul Tol's colour-blind-safe "bright" set.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Callable, Iterable
|
|
11
|
+
|
|
12
|
+
# viridis, 16 anchor colours (R, G, B in 0–255), sampled evenly from matplotlib.
|
|
13
|
+
_COLORMAPS: dict[str, list[tuple[int, int, int]]] = {
|
|
14
|
+
"viridis": [
|
|
15
|
+
(68, 1, 84), (72, 26, 108), (71, 47, 125), (65, 68, 135),
|
|
16
|
+
(57, 86, 140), (49, 104, 142), (42, 120, 142), (35, 136, 142),
|
|
17
|
+
(31, 152, 139), (34, 168, 132), (53, 183, 121), (84, 197, 104),
|
|
18
|
+
(122, 209, 81), (165, 219, 54), (210, 226, 27), (253, 231, 37),
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Paul Tol "bright" — distinct and colour-blind-safe.
|
|
23
|
+
_PALETTE = ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_hex(rgb: tuple[float, float, float]) -> str:
|
|
27
|
+
return "#{:02x}{:02x}{:02x}".format(*(int(max(0, min(255, round(c)))) for c in rgb))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def colormap(name: str = "viridis") -> Callable[[float], tuple[int, int, int]]:
|
|
31
|
+
"""Return a sampler ``t in [0, 1] -> (R, G, B)`` that interpolates the named colormap."""
|
|
32
|
+
anchors = _colormap_anchors(name)
|
|
33
|
+
n = len(anchors)
|
|
34
|
+
|
|
35
|
+
def sample(t: float) -> tuple[int, int, int]:
|
|
36
|
+
t = max(0.0, min(1.0, float(t)))
|
|
37
|
+
pos = t * (n - 1)
|
|
38
|
+
i = int(math.floor(pos))
|
|
39
|
+
if i >= n - 1:
|
|
40
|
+
return anchors[-1]
|
|
41
|
+
frac = pos - i
|
|
42
|
+
a, b = anchors[i], anchors[i + 1]
|
|
43
|
+
return tuple(a[k] + (b[k] - a[k]) * frac for k in range(3))
|
|
44
|
+
|
|
45
|
+
return sample
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def colormap_hex(name: str = "viridis") -> list[str]:
|
|
49
|
+
"""The colormap's anchor colours as hex — for a gradient bar."""
|
|
50
|
+
return [to_hex(rgb) for rgb in _colormap_anchors(name)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _colormap_anchors(name: str) -> list[tuple[int, int, int]]:
|
|
54
|
+
anchors = _COLORMAPS.get(name.lower())
|
|
55
|
+
if anchors is None:
|
|
56
|
+
raise ValueError(f"unknown colormap {name!r}; available: {sorted(_COLORMAPS)}")
|
|
57
|
+
return anchors
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def palette(labels: Iterable) -> dict:
|
|
61
|
+
"""A ``{label: hex colour}`` map over ``labels`` (sorted for stability), from the bright set."""
|
|
62
|
+
ordered = sorted(set(labels), key=str)
|
|
63
|
+
return {label: _PALETTE[i % len(_PALETTE)] for i, label in enumerate(ordered)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize(values: Iterable[float]) -> tuple[float, float, Callable[[float], float]]:
|
|
67
|
+
"""Return ``(vmin, vmax, to_unit)`` where ``to_unit(v)`` maps ``[vmin, vmax] -> [0, 1]``."""
|
|
68
|
+
nums = [float(v) for v in values]
|
|
69
|
+
vmin, vmax = min(nums), max(nums)
|
|
70
|
+
span = (vmax - vmin) or 1.0
|
|
71
|
+
return vmin, vmax, lambda v: (float(v) - vmin) / span
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_number(v) -> bool:
|
|
75
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def map_values(values: dict, *, cmap: str = "viridis",
|
|
79
|
+
palette: dict | None = None) -> tuple[dict, dict]:
|
|
80
|
+
"""Turn ``{key: value}`` into ``({key: hex colour}, scale)``, dispatching on the data: numbers get
|
|
81
|
+
the colormap (and a ``continuous`` scale for a colour bar), labels get a palette (and a
|
|
82
|
+
``categorical`` scale for a legend). ``scale`` is ``None`` if there is nothing to colour."""
|
|
83
|
+
present = {k: v for k, v in values.items() if v is not None}
|
|
84
|
+
if not present:
|
|
85
|
+
return {}, None
|
|
86
|
+
if all(_is_number(v) for v in present.values()):
|
|
87
|
+
vmin, vmax, to_unit = normalize(present.values())
|
|
88
|
+
sample = colormap(cmap)
|
|
89
|
+
colors = {k: to_hex(sample(to_unit(v))) for k, v in present.items()}
|
|
90
|
+
return colors, {"kind": "continuous", "vmin": vmin, "vmax": vmax, "cmap": cmap}
|
|
91
|
+
pal = palette or globals()["palette"](present.values())
|
|
92
|
+
return {k: pal[v] for k, v in present.items()}, {"kind": "categorical", "palette": pal}
|
phylustrator/compose.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
wanted = set(panel.rows)
|
|
58
|
+
rows = [(t.name, t.y) for t in geom.tips if t.name in wanted]
|
|
59
|
+
if rows:
|
|
60
|
+
panel.draw(canvas, tree_w + gap, width - pad, rows, canvas.style)
|
|
61
|
+
return Composite(canvas)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The **genomes** domain — plot genomes, synteny and alignments. Same grammar as ``trees``:
|
|
2
|
+
``phylustrator.genomes.plot(genome) + layer + …``
|
|
3
|
+
|
|
4
|
+
import phylustrator as ph
|
|
5
|
+
G = ph.zombi.read_genomes("run") # or ph.genomes.read_gff("genome.gff")
|
|
6
|
+
(ph.genomes.plot(G["n12"], layout="circular") + ph.genomes.genes(by="family")).save("ring.png")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .figure import Figure, StackFigure, plot, stack
|
|
12
|
+
from .genome import Chromosome, Gene, Genome
|
|
13
|
+
from .io import read_gff
|
|
14
|
+
from .layers import genes, highlight, position_axis, synteny
|
|
15
|
+
from .matrix import Alignment, Matrix
|
|
16
|
+
from .panels import alignment, heatmap, states
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Gene", "Chromosome", "Genome", "read_gff",
|
|
20
|
+
"plot", "stack", "Figure", "StackFigure",
|
|
21
|
+
"genes", "synteny", "highlight", "position_axis",
|
|
22
|
+
"Matrix", "Alignment", "heatmap", "alignment", "states",
|
|
23
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The figure — ``plot(genome)`` + layers, then render. Mirrors Phylustrator's grammar.
|
|
2
|
+
|
|
3
|
+
fig = plot(genome, layout="linear") + genes(by="family") + position_axis()
|
|
4
|
+
fig.save("map.svg")
|
|
5
|
+
|
|
6
|
+
fig = stack([g1, g2, g3]) + genes() + synteny() # several genomes, one per track
|
|
7
|
+
|
|
8
|
+
A **layer** is a callable ``(canvas, primary, layout, style) -> None`` — the whole extension contract.
|
|
9
|
+
``primary`` is the genome (or the first, for a stack); layers read the self-describing ``layout``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from ..render import Canvas
|
|
17
|
+
from ..style import Style
|
|
18
|
+
from .layout import Layout, circular, linear, stacked
|
|
19
|
+
from .track import draw_genes
|
|
20
|
+
|
|
21
|
+
Layer = Callable[[Canvas, object, Layout, Style], None]
|
|
22
|
+
|
|
23
|
+
_LAYOUTS = {"linear": linear, "circular": circular}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Figure:
|
|
27
|
+
def __init__(self, genome, *, layout: str = "linear", coordinates: str = "ordered",
|
|
28
|
+
style: Style | None = None, layers: tuple = ()) -> None:
|
|
29
|
+
if layout not in _LAYOUTS:
|
|
30
|
+
raise ValueError(f"unknown layout {layout!r}; choose from {sorted(_LAYOUTS)}")
|
|
31
|
+
self.genome = genome
|
|
32
|
+
self.layout = layout
|
|
33
|
+
self.coordinates = coordinates
|
|
34
|
+
self.style = style or Style()
|
|
35
|
+
self.layers = tuple(layers)
|
|
36
|
+
|
|
37
|
+
def _make_layout(self) -> Layout:
|
|
38
|
+
return _LAYOUTS[self.layout](self.genome, coordinates=self.coordinates)
|
|
39
|
+
|
|
40
|
+
def _clone(self, **kw) -> "Figure":
|
|
41
|
+
base = dict(layout=self.layout, coordinates=self.coordinates, style=self.style,
|
|
42
|
+
layers=self.layers)
|
|
43
|
+
base.update(kw)
|
|
44
|
+
return Figure(self.genome, **base)
|
|
45
|
+
|
|
46
|
+
def __add__(self, layer: Layer) -> "Figure":
|
|
47
|
+
return self._clone(layers=self.layers + (layer,))
|
|
48
|
+
|
|
49
|
+
def _build(self) -> Canvas:
|
|
50
|
+
layout = self._make_layout()
|
|
51
|
+
canvas = Canvas(self.style, layout.xlim, layout.ylim, equal_aspect=layout.equal_aspect)
|
|
52
|
+
_draw_base(canvas, layout, self.style)
|
|
53
|
+
primary = layout.track_order[0] if layout.track_order else None
|
|
54
|
+
for layer in self.layers:
|
|
55
|
+
layer(canvas, primary, layout, self.style)
|
|
56
|
+
return canvas
|
|
57
|
+
|
|
58
|
+
def as_svg(self) -> str:
|
|
59
|
+
return self._build().as_svg()
|
|
60
|
+
|
|
61
|
+
def save(self, path):
|
|
62
|
+
return self._build().save(path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StackFigure(Figure):
|
|
66
|
+
"""A figure over several genomes (one track each). Same grammar; a different layout."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, genomes, *, coordinates: str = "ordered", style: Style | None = None,
|
|
69
|
+
layers: tuple = ()) -> None:
|
|
70
|
+
self.genomes = list(genomes)
|
|
71
|
+
self.layout = "stacked"
|
|
72
|
+
self.coordinates = coordinates
|
|
73
|
+
n = len(self.genomes)
|
|
74
|
+
self.style = style or Style(height=70.0 + 82.0 * n, gene_height=0.4)
|
|
75
|
+
self.layers = tuple(layers)
|
|
76
|
+
self.genome = self.genomes[0] if self.genomes else None
|
|
77
|
+
|
|
78
|
+
def _make_layout(self) -> Layout:
|
|
79
|
+
return stacked(self.genomes, coordinates=self.coordinates)
|
|
80
|
+
|
|
81
|
+
def _clone(self, **kw) -> "StackFigure":
|
|
82
|
+
base = dict(coordinates=self.coordinates, style=self.style, layers=self.layers)
|
|
83
|
+
base.update({k: v for k, v in kw.items() if k in ("coordinates", "style", "layers")})
|
|
84
|
+
return StackFigure(self.genomes, **base)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plot(genome, *, layout: str = "linear", coordinates: str = "ordered",
|
|
88
|
+
style: Style | None = None) -> Figure:
|
|
89
|
+
"""Start a figure for one ``genome``. Add layers with ``+``, then :meth:`Figure.save`."""
|
|
90
|
+
return Figure(genome, layout=layout, coordinates=coordinates, style=style)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def stack(genomes, *, coordinates: str = "ordered", style: Style | None = None) -> StackFigure:
|
|
94
|
+
"""Start a figure comparing several ``genomes``, one horizontal track each (top genome first)."""
|
|
95
|
+
return StackFigure(genomes, coordinates=coordinates, style=style)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _draw_base(canvas: Canvas, layout: Layout, style: Style) -> None:
|
|
99
|
+
"""The base map: a faint backbone per track, and the gene arrows in the default colour (a
|
|
100
|
+
``genes`` layer overdraws them coloured)."""
|
|
101
|
+
if layout.kind == "circular":
|
|
102
|
+
for R in layout.rings or []:
|
|
103
|
+
canvas.data_ring(R, "#d8ddda", 1.4)
|
|
104
|
+
else:
|
|
105
|
+
for y, x0, x1 in layout.backbones:
|
|
106
|
+
canvas.line(x0, y, x1, y, "#d8ddda", 1.4)
|
|
107
|
+
draw_genes(canvas, layout, lambda gene: style.default_color, style)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The genome data model — :class:`Gene`, :class:`Chromosome`, :class:`Genome`.
|
|
2
|
+
|
|
3
|
+
Structure only: a genome is chromosomes of ordered genes, and knows nothing about how it is drawn. The
|
|
4
|
+
dataclasses use ``eq=False`` so instances hash by identity (a layout keys its boxes by gene).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(eq=False)
|
|
13
|
+
class Gene:
|
|
14
|
+
"""One gene on a chromosome: its ``family`` (shared across genomes — the unit of colour and
|
|
15
|
+
homology), its ``copy`` name, its ``strand`` (+1 / −1), and its ``position`` (rank order). Optional
|
|
16
|
+
``start`` / ``end`` carry nucleotide coordinates for the nucleotide resolution."""
|
|
17
|
+
|
|
18
|
+
family: str
|
|
19
|
+
copy: str = ""
|
|
20
|
+
strand: int = 1
|
|
21
|
+
position: int = 0
|
|
22
|
+
start: float | None = None
|
|
23
|
+
end: float | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(eq=False)
|
|
27
|
+
class Chromosome:
|
|
28
|
+
id: str
|
|
29
|
+
genes: list = field(default_factory=list)
|
|
30
|
+
topology: str = "linear" # "linear" | "circular"
|
|
31
|
+
length: float | None = None # nucleotide length, if known
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(eq=False)
|
|
35
|
+
class Genome:
|
|
36
|
+
name: str
|
|
37
|
+
chromosomes: list = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def genes(self) -> list:
|
|
41
|
+
return [g for chrom in self.chromosomes for g in chrom.genes]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""General genome input — reading a **GFF3** into :class:`~phylustrator.genomes.genome.Genome`
|
|
2
|
+
objects. Reading a ZOMBI2 *run* (its own file formats) lives in :mod:`phylustrator.zombi`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .genome import Chromosome, Gene, Genome
|
|
10
|
+
|
|
11
|
+
__all__ = ["read_gff"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_gff(source: str | Path, *, feature: str = "gene", name_attr: str = "locus_tag") -> dict:
|
|
15
|
+
"""Read a **GFF3** into ``{seqid: Genome}`` — real genes at their real base coordinates.
|
|
16
|
+
|
|
17
|
+
Genes are the rows whose type is ``feature`` (default ``"gene"``); each gets its ``start`` / ``end``
|
|
18
|
+
(bp), ``strand``, and a family/name from ``name_attr`` in column 9 (falling back to ``Name`` / ``ID``).
|
|
19
|
+
A ``##sequence-region`` line or a ``region`` feature sets the replicon length and circularity."""
|
|
20
|
+
path = Path(source)
|
|
21
|
+
per: dict[str, dict] = {}
|
|
22
|
+
for raw in path.read_text().splitlines():
|
|
23
|
+
if raw.startswith("##sequence-region"):
|
|
24
|
+
p = raw.split()
|
|
25
|
+
if len(p) >= 4:
|
|
26
|
+
per.setdefault(p[1], _blank())["length"] = float(p[3])
|
|
27
|
+
continue
|
|
28
|
+
if raw.startswith("#") or not raw.strip():
|
|
29
|
+
continue
|
|
30
|
+
c = raw.split("\t")
|
|
31
|
+
if len(c) < 9:
|
|
32
|
+
continue
|
|
33
|
+
seqid, _src, ftype, start, end, _score, strand, _phase, attrs = c[:9]
|
|
34
|
+
info = per.setdefault(seqid, _blank())
|
|
35
|
+
if ftype == "region" and "Is_circular=true" in attrs:
|
|
36
|
+
info["circular"] = True
|
|
37
|
+
if ftype != feature:
|
|
38
|
+
continue
|
|
39
|
+
a = dict(kv.split("=", 1) for kv in attrs.split(";") if "=" in kv)
|
|
40
|
+
fam = a.get(name_attr) or a.get("Name") or a.get("ID") or ""
|
|
41
|
+
info["genes"].append(Gene(family=fam, strand=(-1 if strand == "-" else 1),
|
|
42
|
+
start=float(start), end=float(end)))
|
|
43
|
+
info["length"] = max(info["length"], float(end))
|
|
44
|
+
genomes = {}
|
|
45
|
+
for seqid, info in per.items():
|
|
46
|
+
genes = sorted(info["genes"], key=lambda g: g.start)
|
|
47
|
+
for rank, g in enumerate(genes):
|
|
48
|
+
g.position = rank
|
|
49
|
+
topo = "circular" if info["circular"] else "linear"
|
|
50
|
+
genomes[seqid] = Genome(name=seqid, chromosomes=[
|
|
51
|
+
Chromosome(id=seqid, genes=genes, topology=topo, length=info["length"])])
|
|
52
|
+
return genomes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _blank() -> dict:
|
|
56
|
+
return {"genes": [], "length": 0.0, "circular": False}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Layers — the composable decorations added to a genome figure with ``+``."""
|
|
2
|
+
|
|
3
|
+
from .genes import genes
|
|
4
|
+
from .guides import position_axis
|
|
5
|
+
from .highlight import highlight
|
|
6
|
+
from .synteny import synteny
|
|
7
|
+
|
|
8
|
+
__all__ = ["genes", "position_axis", "synteny", "highlight"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The gene-colouring layer — paint the arrows by an attribute (default the gene family).
|
|
2
|
+
|
|
3
|
+
Families get consistent colours across genomes (family "5" is the same colour everywhere), which is
|
|
4
|
+
what makes stacked genomes read as synteny.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...color import colormap, to_hex
|
|
10
|
+
from ..track import draw_genes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _key(value: str):
|
|
14
|
+
return (0, int(value)) if str(value).lstrip("-").isdigit() else (1, str(value))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def genes(by: str = "family", *, cmap: str = "viridis", palette: dict | None = None):
|
|
18
|
+
"""Colour gene arrows by ``by`` (an attribute of each gene). Returns a layer."""
|
|
19
|
+
|
|
20
|
+
def layer(canvas, primary, layout, style):
|
|
21
|
+
keys = sorted({str(getattr(g, by)) for g in layout.genes}, key=_key)
|
|
22
|
+
if palette is not None:
|
|
23
|
+
color_of = dict(palette)
|
|
24
|
+
else:
|
|
25
|
+
sample = colormap(cmap)
|
|
26
|
+
n = len(keys)
|
|
27
|
+
color_of = {k: to_hex(sample(i / (n - 1) if n > 1 else 0.5)) for i, k in enumerate(keys)}
|
|
28
|
+
canvas.scale = {"kind": "genes", "colors": color_of, "by": by}
|
|
29
|
+
|
|
30
|
+
def color(gene):
|
|
31
|
+
return color_of.get(str(getattr(gene, by)), style.default_color)
|
|
32
|
+
|
|
33
|
+
draw_genes(canvas, layout, color, style)
|
|
34
|
+
|
|
35
|
+
return layer
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Guide layers — the chrome that reads the coordinates.
|
|
2
|
+
|
|
3
|
+
``position_axis`` adapts to the layout: a horizontal ruler for ``linear``, and an inner coordinate
|
|
4
|
+
**ring** (base-position ticks around the circle) for ``circular`` — the standard reference on a
|
|
5
|
+
circular genome map.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def position_axis(label: str = "position", *, ticks: int = 6):
|
|
14
|
+
"""A coordinate axis in the layout's units: a horizontal ruler (``linear``) or an inner ring of
|
|
15
|
+
base-position ticks (``circular``)."""
|
|
16
|
+
|
|
17
|
+
def layer(canvas, primary, layout, style):
|
|
18
|
+
if layout.kind == "circular":
|
|
19
|
+
_circular(canvas, layout, style)
|
|
20
|
+
else:
|
|
21
|
+
_linear(canvas, layout, style, label, ticks)
|
|
22
|
+
|
|
23
|
+
return layer
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _linear(canvas, layout, style, label, ticks) -> None:
|
|
27
|
+
_, height = canvas.size
|
|
28
|
+
m = style.margin
|
|
29
|
+
x0, x1 = layout.xlim
|
|
30
|
+
y = height - m * 0.62
|
|
31
|
+
canvas.raw_line(canvas.px(x0), y, canvas.px(x1), y, "#333333", 1.2)
|
|
32
|
+
small = style.font_size * 0.85
|
|
33
|
+
for i in range(ticks):
|
|
34
|
+
t = x0 + (x1 - x0) * i / (ticks - 1)
|
|
35
|
+
tx = canvas.px(t)
|
|
36
|
+
canvas.raw_line(tx, y, tx, y + 5, "#333333", 1.2)
|
|
37
|
+
canvas.raw_text(tx, y + 12, f"{t:.0f}", anchor="middle", size=small)
|
|
38
|
+
if label:
|
|
39
|
+
mid = (canvas.px(x0) + canvas.px(x1)) / 2
|
|
40
|
+
canvas.raw_text(mid, y + 24, label, anchor="middle", size=style.font_size)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _circular(canvas, layout, style) -> None:
|
|
44
|
+
total = layout.totals[0] if layout.totals else 1.0
|
|
45
|
+
start, sweep = layout.angle_start, layout.angle_sweep
|
|
46
|
+
inner = (min(layout.rings) if layout.rings else 0.85) - layout.ring_hh - 0.06
|
|
47
|
+
canvas.data_ring(inner, "#c7d0cc", 1.0) # the coordinate ring
|
|
48
|
+
step = _nice_step(total, 8)
|
|
49
|
+
small = style.font_size * 0.9
|
|
50
|
+
v = 0.0
|
|
51
|
+
while v < total - step * 1e-6:
|
|
52
|
+
a = start - (v / total) * sweep
|
|
53
|
+
canvas.line(inner * math.cos(a), inner * math.sin(a),
|
|
54
|
+
(inner - 0.03) * math.cos(a), (inner - 0.03) * math.sin(a), "#5a6763", 1.1)
|
|
55
|
+
lx, ly = (inner - 0.10) * math.cos(a), (inner - 0.10) * math.sin(a)
|
|
56
|
+
canvas.text(lx, ly, _fmt_bp(v), anchor="middle", size=small)
|
|
57
|
+
v += step
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _nice_step(span: float, target: int) -> float:
|
|
61
|
+
raw = span / max(target, 1)
|
|
62
|
+
if raw <= 0:
|
|
63
|
+
return 1.0
|
|
64
|
+
exp = math.floor(math.log10(raw))
|
|
65
|
+
base = raw / 10 ** exp
|
|
66
|
+
nice = 1 if base < 1.5 else 2 if base < 3.5 else 5 if base < 7.5 else 10
|
|
67
|
+
return nice * 10 ** exp
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _fmt_bp(v: float) -> str:
|
|
71
|
+
v = int(round(v))
|
|
72
|
+
if v == 0:
|
|
73
|
+
return "0"
|
|
74
|
+
if v >= 1_000_000:
|
|
75
|
+
return f"{v // 1_000_000} Mb" if v % 1_000_000 == 0 else f"{v / 1_000_000:.1f} Mb"
|
|
76
|
+
if v >= 1_000:
|
|
77
|
+
return f"{v // 1_000} kb" if v % 1_000 == 0 else f"{v / 1_000:.0f} kb"
|
|
78
|
+
return str(v)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The highlight layer — mark a span of a genome (a rearranged segment, a region of interest).
|
|
2
|
+
|
|
3
|
+
Shades the ranks ``[start, end]`` of one genome's chromosome with a tinted, outlined band that the
|
|
4
|
+
gene arrows read through. Works on a single ``plot`` or on one track of a ``stack``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def highlight(genome, chromosome=None, start: int = 0, end: int = 0, *,
|
|
11
|
+
color: str = "#e8a33d", label: str | None = None, pad: float = 0.12):
|
|
12
|
+
"""Highlight positions ``start``..``end`` (inclusive) on ``genome`` / ``chromosome``.
|
|
13
|
+
|
|
14
|
+
``chromosome`` may be a :class:`Chromosome`, its ``id``, or ``None`` (any chromosome)."""
|
|
15
|
+
|
|
16
|
+
def _matches(owner):
|
|
17
|
+
g, chrom = owner
|
|
18
|
+
if g is not genome:
|
|
19
|
+
return False
|
|
20
|
+
return chromosome is None or chrom is chromosome or chrom.id == chromosome
|
|
21
|
+
|
|
22
|
+
def layer(canvas, primary, layout, style):
|
|
23
|
+
sel = [gene for gene in layout.genes
|
|
24
|
+
if _matches(layout.owner[id(gene)]) and start <= gene.position <= end]
|
|
25
|
+
if not sel:
|
|
26
|
+
return
|
|
27
|
+
boxes = [layout.box(g) for g in sel]
|
|
28
|
+
x0 = min(b[0] for b in boxes)
|
|
29
|
+
x1 = max(b[1] for b in boxes)
|
|
30
|
+
y = boxes[0][2]
|
|
31
|
+
hh = style.gene_height / 2.0 + pad
|
|
32
|
+
canvas.region(x0, y - hh, x1, y + hh, fill=color, opacity=0.2,
|
|
33
|
+
stroke=color, stroke_width=1.4, rx=4.0)
|
|
34
|
+
if label:
|
|
35
|
+
canvas.text((x0 + x1) / 2.0, y - hh, label, dy=-6, anchor="middle",
|
|
36
|
+
color=color, size=style.font_size)
|
|
37
|
+
|
|
38
|
+
return layer
|