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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The synteny layer — ribbons linking same-family genes between adjacent stacked genomes.
|
|
2
|
+
|
|
3
|
+
Runs after ``genes`` when present, so ribbons inherit the family colours the ``genes`` layer chose;
|
|
4
|
+
on its own it falls back to a neutral link colour. Only meaningful on a ``stack``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...color import colormap, to_hex
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _family_colors(layout, canvas):
|
|
13
|
+
scale = getattr(canvas, "scale", None)
|
|
14
|
+
if scale and scale.get("kind") == "genes":
|
|
15
|
+
return scale["colors"]
|
|
16
|
+
fams = sorted({str(g.family) for g in layout.genes})
|
|
17
|
+
sample = colormap("viridis")
|
|
18
|
+
n = len(fams)
|
|
19
|
+
return {f: to_hex(sample(i / (n - 1) if n > 1 else 0.5)) for i, f in enumerate(fams)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def synteny(*, by: str = "family", opacity: float = 0.3, color: str | None = None):
|
|
23
|
+
"""Link genes sharing ``by`` between neighbouring tracks with a curved ribbon. ``color`` overrides
|
|
24
|
+
the per-family colour with a single neutral tone."""
|
|
25
|
+
|
|
26
|
+
def layer(canvas, primary, layout, style):
|
|
27
|
+
tracks = layout.track_order
|
|
28
|
+
if len(tracks) < 2:
|
|
29
|
+
return
|
|
30
|
+
colors = _family_colors(layout, canvas)
|
|
31
|
+
hh = style.gene_height / 2.0
|
|
32
|
+
# genes grouped by (track index, key)
|
|
33
|
+
per_track = [{} for _ in tracks]
|
|
34
|
+
index = {id(g): t for t, gen in enumerate(tracks)
|
|
35
|
+
for g in gen.genes if id(g) in layout.boxes}
|
|
36
|
+
for g in layout.genes:
|
|
37
|
+
t = index.get(id(g))
|
|
38
|
+
if t is None:
|
|
39
|
+
continue
|
|
40
|
+
per_track[t].setdefault(str(getattr(g, by)), []).append(g)
|
|
41
|
+
for t in range(len(tracks) - 1):
|
|
42
|
+
upper, lower = per_track[t], per_track[t + 1]
|
|
43
|
+
for key, ups in upper.items():
|
|
44
|
+
downs = lower.get(key)
|
|
45
|
+
if not downs:
|
|
46
|
+
continue
|
|
47
|
+
fill = color or colors.get(key, style.default_color)
|
|
48
|
+
ups = sorted(ups, key=lambda g: layout.box(g)[0])
|
|
49
|
+
downs = sorted(downs, key=lambda g: layout.box(g)[0])
|
|
50
|
+
for i, u in enumerate(ups):
|
|
51
|
+
d = downs[min(i, len(downs) - 1)] # pair by copy order
|
|
52
|
+
ux0, ux1, uy = layout.box(u)
|
|
53
|
+
dx0, dx1, dy = layout.box(d)
|
|
54
|
+
canvas.ribbon(ux0, ux1, uy + hh, dx0, dx1, dy - hh, fill=fill, opacity=opacity)
|
|
55
|
+
|
|
56
|
+
return layer
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Layouts — place genes in an abstract coordinate space, the renderer maps it to the page.
|
|
2
|
+
|
|
3
|
+
- ``linear`` — one genome, a horizontal track per chromosome. The **ordered** resolution spaces genes
|
|
4
|
+
equally by rank; **nucleotide** uses their base coordinates (cladogram vs. phylogram).
|
|
5
|
+
- ``circular`` — the same map wrapped onto a ring, one concentric ring per chromosome (Phylustrator's
|
|
6
|
+
radial). A ``linear`` plot of a circular genome *is* the linearisation.
|
|
7
|
+
- ``stacked`` — several genomes, one horizontal track each, for synteny comparison.
|
|
8
|
+
|
|
9
|
+
A :class:`Layout` is **self-describing**: it carries the genes it placed (draw order), each gene's
|
|
10
|
+
owner ``(genome, chromosome)``, the backbones to draw, and the vertical track order — so the drawer and
|
|
11
|
+
every layer read the layout, never the genome, and single/stacked/circular all share one path.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Layout:
|
|
22
|
+
kind: str
|
|
23
|
+
boxes: dict # linear: id(gene)->(x0,x1,y); circular: id(gene)->(a0,a1,R)
|
|
24
|
+
xlim: tuple
|
|
25
|
+
ylim: tuple
|
|
26
|
+
rows: int = 1
|
|
27
|
+
genes: list = field(default_factory=list) # gene objects, draw order
|
|
28
|
+
owner: dict = field(default_factory=dict) # id(gene) -> (genome, chromosome)
|
|
29
|
+
backbones: list = field(default_factory=list) # [(y, x0, x1)] faint tracks (linear/stacked)
|
|
30
|
+
track_order: list = field(default_factory=list) # genomes top->bottom (synteny adjacency)
|
|
31
|
+
rings: list | None = None # circular: centre radius per chromosome
|
|
32
|
+
ring_hh: float = 0.0 # circular: gene half-height, radius units
|
|
33
|
+
equal_aspect: bool = False # circular keeps the rings round
|
|
34
|
+
angle_start: float = 0.0 # circular: angle (rad) of position 0
|
|
35
|
+
angle_sweep: float = 0.0 # circular: angular span (rad) of a chromosome
|
|
36
|
+
totals: list = field(default_factory=list) # circular: coordinate span per ring (bp or genes)
|
|
37
|
+
|
|
38
|
+
def box(self, gene):
|
|
39
|
+
return self.boxes[id(gene)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def linear(genome, *, coordinates: str = "ordered", gap: float = 0.16) -> Layout:
|
|
43
|
+
"""Genes on one horizontal track per chromosome."""
|
|
44
|
+
boxes, owner, backbones, placed = {}, {}, [], []
|
|
45
|
+
for row, chrom in enumerate(genome.chromosomes):
|
|
46
|
+
xs = []
|
|
47
|
+
for gene in chrom.genes:
|
|
48
|
+
if coordinates == "nucleotide" and gene.start is not None:
|
|
49
|
+
x0, x1 = float(gene.start), float(gene.end)
|
|
50
|
+
else:
|
|
51
|
+
x0, x1 = gene.position + gap / 2, gene.position + 1 - gap / 2
|
|
52
|
+
boxes[id(gene)] = (x0, x1, float(row))
|
|
53
|
+
owner[id(gene)] = (genome, chrom)
|
|
54
|
+
placed.append(gene)
|
|
55
|
+
xs += [x0, x1]
|
|
56
|
+
if xs:
|
|
57
|
+
backbones.append((float(row), min(xs), max(xs)))
|
|
58
|
+
allx = [v for b in boxes.values() for v in b[:2]] or [0.0, 1.0]
|
|
59
|
+
rows = len(genome.chromosomes)
|
|
60
|
+
return Layout("linear", boxes, (min(allx), max(allx)), (-0.5, rows - 0.5), rows=rows,
|
|
61
|
+
genes=placed, owner=owner, backbones=backbones, track_order=[genome])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stacked(genomes, *, coordinates: str = "ordered", gap: float = 0.16,
|
|
65
|
+
chrom_gap: float = 1.0) -> Layout:
|
|
66
|
+
"""Several genomes, one horizontal track each (top genome first). Chromosomes of a genome sit
|
|
67
|
+
left-to-right on its track separated by ``chrom_gap``. Same-family genes line up by colour, and a
|
|
68
|
+
``synteny`` layer links them between adjacent tracks."""
|
|
69
|
+
boxes, owner, backbones, placed = {}, {}, [], []
|
|
70
|
+
for row, genome in enumerate(genomes):
|
|
71
|
+
offset, xs = 0.0, []
|
|
72
|
+
for chrom in genome.chromosomes:
|
|
73
|
+
for gene in chrom.genes:
|
|
74
|
+
if coordinates == "nucleotide" and gene.start is not None:
|
|
75
|
+
x0, x1 = offset + float(gene.start), offset + float(gene.end)
|
|
76
|
+
else:
|
|
77
|
+
x0, x1 = offset + gene.position + gap / 2, offset + gene.position + 1 - gap / 2
|
|
78
|
+
boxes[id(gene)] = (x0, x1, float(row))
|
|
79
|
+
owner[id(gene)] = (genome, chrom)
|
|
80
|
+
placed.append(gene)
|
|
81
|
+
xs += [x0, x1]
|
|
82
|
+
span = (len(chrom.genes)) if coordinates != "nucleotide" else float(chrom.length or 0.0)
|
|
83
|
+
offset += span + chrom_gap
|
|
84
|
+
if xs:
|
|
85
|
+
backbones.append((float(row), min(xs), max(xs)))
|
|
86
|
+
allx = [v for b in boxes.values() for v in b[:2]] or [0.0, 1.0]
|
|
87
|
+
n = len(genomes)
|
|
88
|
+
return Layout("stacked", boxes, (min(allx), max(allx)), (-0.6, n - 0.4), rows=n,
|
|
89
|
+
genes=placed, owner=owner, backbones=backbones, track_order=list(genomes))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def circular(genome, *, coordinates: str = "ordered", gap: float = 0.16,
|
|
93
|
+
start_deg: float = 90.0, break_deg: float = 0.0,
|
|
94
|
+
band: float = 0.34, ring_gap: float = 0.10, min_deg: float = 2.2) -> Layout:
|
|
95
|
+
"""Genes wrapped onto a ring, one concentric ring per chromosome (chromosome 0 outermost).
|
|
96
|
+
|
|
97
|
+
Angles sweep **clockwise** from the top. By default the ring is closed (``break_deg=0``) so genes
|
|
98
|
+
are evenly spaced all the way round; set ``break_deg`` to leave a wedge marking a linear
|
|
99
|
+
chromosome's ends. ``coordinates`` chooses equal angular slots by **rank** (``"ordered"``) or
|
|
100
|
+
base-proportional arcs (``"nucleotide"``)."""
|
|
101
|
+
start = math.radians(start_deg)
|
|
102
|
+
sweep = 2.0 * math.pi - math.radians(break_deg)
|
|
103
|
+
boxes, owner, placed, rings, totals = {}, {}, [], [], []
|
|
104
|
+
for k, chrom in enumerate(genome.chromosomes):
|
|
105
|
+
R = 1.0 - band / 2.0 - k * (band + ring_gap)
|
|
106
|
+
rings.append(R)
|
|
107
|
+
n = len(chrom.genes)
|
|
108
|
+
nuc = coordinates == "nucleotide" and n and chrom.genes[0].start is not None
|
|
109
|
+
total = float(chrom.length or (chrom.genes[-1].end - chrom.genes[0].start) or 1.0) if nuc \
|
|
110
|
+
else float(n or 1)
|
|
111
|
+
totals.append(total)
|
|
112
|
+
# cap the minimum arc so a gene-dense genome (a real GFF) never forces genes to overlap
|
|
113
|
+
min_arc = min(math.radians(min_deg), 0.9 * sweep / max(n, 1))
|
|
114
|
+
for rank, gene in enumerate(chrom.genes): # rank, so "ordered" is even with no holes
|
|
115
|
+
lo_v, hi_v = (float(gene.start), float(gene.end)) if nuc \
|
|
116
|
+
else (rank + gap / 2.0, rank + 1.0 - gap / 2.0)
|
|
117
|
+
a0 = start - (lo_v / total) * sweep # clockwise: angle decreases with position
|
|
118
|
+
a1 = start - (hi_v / total) * sweep
|
|
119
|
+
if abs(a1 - a0) < min_arc: # keep tiny (nucleotide) genes visible
|
|
120
|
+
mid = (a0 + a1) / 2.0
|
|
121
|
+
a0, a1 = mid + min_arc / 2.0, mid - min_arc / 2.0
|
|
122
|
+
boxes[id(gene)] = (a0, a1, R)
|
|
123
|
+
owner[id(gene)] = (genome, chrom)
|
|
124
|
+
placed.append(gene)
|
|
125
|
+
hh = band * 0.11 # thin arrow band so arrowheads read on the ring
|
|
126
|
+
outer = (rings[0] if rings else 1.0) + hh
|
|
127
|
+
lim = (-outer, outer)
|
|
128
|
+
return Layout("circular", boxes, lim, lim, rows=len(genome.chromosomes),
|
|
129
|
+
genes=placed, owner=owner, rings=rings, ring_hh=hh, equal_aspect=True,
|
|
130
|
+
track_order=[genome], angle_start=start, angle_sweep=sweep, totals=totals)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Tabular data to show beside a tree — labelled grids.
|
|
2
|
+
|
|
3
|
+
A :class:`Matrix` is rows × columns of numbers with labels on both (e.g. a gene-family profile:
|
|
4
|
+
genomes × families). An :class:`Alignment` is rows × sites of residues. Both are consumed by the
|
|
5
|
+
``heatmap`` / ``alignment`` panels and placed by :func:`~phylustrator.compose.beside`. Readers that
|
|
6
|
+
build these from a ZOMBI2 run live in :mod:`phylustrator.zombi`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Matrix:
|
|
16
|
+
rows: list # row labels (e.g. genomes / tree tips)
|
|
17
|
+
cols: list # column labels (e.g. families)
|
|
18
|
+
values: list # values[i][j] aligned to rows[i], cols[j]
|
|
19
|
+
|
|
20
|
+
def row(self, label):
|
|
21
|
+
return self.values[self.rows.index(label)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Alignment:
|
|
26
|
+
rows: list # row labels (e.g. genomes / tree tips)
|
|
27
|
+
seqs: dict # label -> sequence string
|
|
28
|
+
kind: str = "nt" # "nt" | "aa"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def length(self) -> int:
|
|
32
|
+
return max((len(s) for s in self.seqs.values()), default=0)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Panels — a matrix or an alignment drawn as a grid, its rows placed by whatever calls it.
|
|
2
|
+
|
|
3
|
+
A **panel** knows its ``rows`` (labels) and draws itself into a pixel band with :meth:`draw`, given the
|
|
4
|
+
pixel ``y`` of each row it should draw. :func:`~genustrator.compose.beside` supplies those y's from a
|
|
5
|
+
Phylustrator tree's tips, so the grid lines up with the phylogeny; a panel never positions its own
|
|
6
|
+
rows. ``heatmap`` shows a :class:`~genustrator.matrix.Matrix`; ``alignment`` shows residues.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..color import colormap, colormap_hex, to_hex
|
|
12
|
+
|
|
13
|
+
# A clean nucleotide palette; unknown residues fall back to a neutral grey.
|
|
14
|
+
NT_COLORS = {"A": "#3a923a", "C": "#3a6ea5", "G": "#e0a327", "T": "#c1443c",
|
|
15
|
+
"U": "#c1443c", "-": "#e9ecef", "N": "#c8cdd2"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _row_height(rows) -> float:
|
|
19
|
+
ys = sorted(y for _, y in rows)
|
|
20
|
+
gaps = [b - a for a, b in zip(ys, ys[1:])]
|
|
21
|
+
return (min(gaps) if gaps else 40.0) * 0.82
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Heatmap:
|
|
25
|
+
def __init__(self, matrix, *, cmap="viridis", vmin=None, vmax=None,
|
|
26
|
+
col_labels=None, grid="#ffffff", title=None):
|
|
27
|
+
self.matrix = matrix
|
|
28
|
+
self.cmap = cmap
|
|
29
|
+
vals = [v for r in matrix.values for v in r]
|
|
30
|
+
self.vmin = 0.0 if vmin is None else vmin
|
|
31
|
+
self.vmax = (max(vals) if vals else 1.0) if vmax is None else vmax
|
|
32
|
+
# label columns only when there are few enough to read
|
|
33
|
+
self.col_labels = (len(matrix.cols) <= 26) if col_labels is None else col_labels
|
|
34
|
+
self.grid = grid
|
|
35
|
+
self.title = title
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def rows(self):
|
|
39
|
+
return self.matrix.rows
|
|
40
|
+
|
|
41
|
+
def draw(self, canvas, x0, x1, rows, style):
|
|
42
|
+
sample = colormap(self.cmap)
|
|
43
|
+
span = (self.vmax - self.vmin) or 1.0
|
|
44
|
+
ncol = len(self.matrix.cols)
|
|
45
|
+
cw = (x1 - x0) / ncol
|
|
46
|
+
rh = _row_height(rows)
|
|
47
|
+
for label, y in rows:
|
|
48
|
+
values = self.matrix.row(label)
|
|
49
|
+
for j, v in enumerate(values):
|
|
50
|
+
t = (v - self.vmin) / span
|
|
51
|
+
canvas.raw_rect(x0 + j * cw, y - rh / 2, cw, rh,
|
|
52
|
+
fill=to_hex(sample(t)), stroke=self.grid, stroke_width=0.6)
|
|
53
|
+
top = min(y for _, y in rows) - rh / 2
|
|
54
|
+
if self.col_labels:
|
|
55
|
+
for j, c in enumerate(self.matrix.cols):
|
|
56
|
+
cx = x0 + (j + 0.5) * cw
|
|
57
|
+
canvas.raw_text(cx, top - 6, str(c), anchor="start", baseline="alphabetic",
|
|
58
|
+
size=style.font_size * 0.8, rotate=-60)
|
|
59
|
+
if self.title:
|
|
60
|
+
canvas.raw_text((x0 + x1) / 2, top - 26, self.title, anchor="middle",
|
|
61
|
+
size=style.font_size, weight="bold")
|
|
62
|
+
self._colorbar(canvas, x0, x1, max(y for _, y in rows) + rh / 2 + 16, style)
|
|
63
|
+
|
|
64
|
+
def _colorbar(self, canvas, x0, x1, y, style):
|
|
65
|
+
w, h = min(200.0, x1 - x0), 12.0
|
|
66
|
+
canvas.gradient_bar(self.cmap, x0, y, w, h)
|
|
67
|
+
small = style.font_size * 0.85
|
|
68
|
+
lo, hi = int(round(self.vmin)), int(round(self.vmax))
|
|
69
|
+
vals = list(range(lo, hi + 1))
|
|
70
|
+
if len(vals) > 9: # thin out to ~7 integer ticks
|
|
71
|
+
step = max(1, round((hi - lo) / 7))
|
|
72
|
+
vals = list(range(lo, hi + 1, step))
|
|
73
|
+
if vals[-1] != hi:
|
|
74
|
+
vals.append(hi)
|
|
75
|
+
span = (self.vmax - self.vmin) or 1.0
|
|
76
|
+
for v in vals:
|
|
77
|
+
tx = x0 + (v - self.vmin) / span * w
|
|
78
|
+
canvas.raw_line(tx, y + h, tx, y + h + 4, "#555555", 1.0)
|
|
79
|
+
canvas.raw_text(tx, y + h + 6 + small * 0.7, str(v), anchor="middle", size=small)
|
|
80
|
+
canvas.raw_text(x0 + w + 12, y + h / 2, "copies", anchor="start", size=small)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Alignment:
|
|
84
|
+
def __init__(self, alignment, *, palette=None, letters=None, title=None, legend=True):
|
|
85
|
+
self.alignment = alignment
|
|
86
|
+
self.palette = palette or NT_COLORS
|
|
87
|
+
self.letters = letters # None -> auto (draw letters if cells are wide enough)
|
|
88
|
+
self.title = title
|
|
89
|
+
self.legend = legend # a nucleotide colour key below the alignment
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def rows(self):
|
|
93
|
+
return self.alignment.rows
|
|
94
|
+
|
|
95
|
+
def draw(self, canvas, x0, x1, rows, style):
|
|
96
|
+
L = self.alignment.length
|
|
97
|
+
if L == 0:
|
|
98
|
+
return
|
|
99
|
+
cw = (x1 - x0) / L
|
|
100
|
+
rh = _row_height(rows)
|
|
101
|
+
letters = (cw >= 7.0) if self.letters is None else self.letters
|
|
102
|
+
for label, y in rows:
|
|
103
|
+
seq = self.alignment.seqs.get(label, "")
|
|
104
|
+
for s, res in enumerate(seq):
|
|
105
|
+
cx = x0 + s * cw
|
|
106
|
+
canvas.raw_rect(cx, y - rh / 2, cw, rh,
|
|
107
|
+
fill=self.palette.get(res, "#c8cdd2"),
|
|
108
|
+
stroke="#ffffff", stroke_width=0.4)
|
|
109
|
+
if letters:
|
|
110
|
+
canvas.raw_text(cx + cw / 2, y, res, anchor="middle",
|
|
111
|
+
color="#ffffff", size=min(rh, cw) * 0.72, weight="bold")
|
|
112
|
+
top = min(y for _, y in rows) - rh / 2
|
|
113
|
+
# a light ruler every 10 sites
|
|
114
|
+
for s in range(0, L + 1, 10):
|
|
115
|
+
cx = x0 + s * cw
|
|
116
|
+
canvas.raw_line(cx, top - 4, cx, top, "#98a2a8", 1.0)
|
|
117
|
+
canvas.raw_text(cx, top - 7, str(s), anchor="middle", baseline="alphabetic",
|
|
118
|
+
size=style.font_size * 0.75)
|
|
119
|
+
if self.title:
|
|
120
|
+
canvas.raw_text((x0 + x1) / 2, top - 24, self.title, anchor="middle",
|
|
121
|
+
size=style.font_size, weight="bold")
|
|
122
|
+
if self.legend:
|
|
123
|
+
self._legend(canvas, x0, max(y for _, y in rows) + rh / 2 + 22, style)
|
|
124
|
+
|
|
125
|
+
def _legend(self, canvas, x0, y, style):
|
|
126
|
+
sw, fs = 20.0, style.font_size * 1.15 # a visible key
|
|
127
|
+
x = x0
|
|
128
|
+
for res in ("A", "C", "G", "T"):
|
|
129
|
+
canvas.raw_rect(x, y, sw, sw, fill=self.palette.get(res, "#c8cdd2"),
|
|
130
|
+
stroke="#ffffff", stroke_width=0.8)
|
|
131
|
+
canvas.raw_text(x + sw + 6, y + sw / 2, res, anchor="start", size=fs, weight="bold")
|
|
132
|
+
x += sw + 6 + fs * 0.8 + 16
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class States:
|
|
136
|
+
"""A **categorical** matrix panel — each cell coloured by a value→colour ``palette`` (a discrete
|
|
137
|
+
sibling of :class:`Heatmap`: no gradient, no numeric scale). For character-state / presence–absence
|
|
138
|
+
matrices beside a tree — e.g. two binary characters shown as two columns of filled / open cells.
|
|
139
|
+
``legend_labels`` maps a value to the text shown for it in the key (``{"1": "present"}``)."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, matrix, *, palette=None, col_palettes=None, legend=True, legend_labels=None,
|
|
142
|
+
title=None, col_labels=True, grid="#1a1a1a", other="#c8cdd2"):
|
|
143
|
+
if palette is None and col_palettes is None:
|
|
144
|
+
raise ValueError("states() needs palette= (one for all columns) or col_palettes= (per column)")
|
|
145
|
+
self.palette = {str(k): v for k, v in palette.items()} if palette else None
|
|
146
|
+
# a per-column palette overrides the shared one for that column (e.g. one trait per column)
|
|
147
|
+
self.col_palettes = ([{str(k): v for k, v in p.items()} for p in col_palettes]
|
|
148
|
+
if col_palettes else None)
|
|
149
|
+
self.matrix = matrix
|
|
150
|
+
self.legend = legend
|
|
151
|
+
self.legend_labels = {str(k): v for k, v in (legend_labels or {}).items()}
|
|
152
|
+
self.title = title
|
|
153
|
+
self.col_labels = col_labels
|
|
154
|
+
self.grid = grid
|
|
155
|
+
self.other = other # colour for a value not in the palette
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def rows(self):
|
|
159
|
+
return self.matrix.rows
|
|
160
|
+
|
|
161
|
+
def _fill(self, j, v):
|
|
162
|
+
pal = self.col_palettes[j] if self.col_palettes else self.palette
|
|
163
|
+
return pal.get(str(v), self.other)
|
|
164
|
+
|
|
165
|
+
def draw(self, canvas, x0, x1, rows, style):
|
|
166
|
+
ncol = len(self.matrix.cols)
|
|
167
|
+
cw = (x1 - x0) / ncol
|
|
168
|
+
rh = _row_height(rows)
|
|
169
|
+
for label, y in rows:
|
|
170
|
+
for j, v in enumerate(self.matrix.row(label)):
|
|
171
|
+
canvas.raw_rect(x0 + j * cw, y - rh / 2, cw, rh,
|
|
172
|
+
fill=self._fill(j, v),
|
|
173
|
+
stroke=self.grid, stroke_width=0.8)
|
|
174
|
+
top = min(y for _, y in rows) - rh / 2
|
|
175
|
+
if self.col_labels:
|
|
176
|
+
for j, c in enumerate(self.matrix.cols):
|
|
177
|
+
canvas.raw_text(x0 + (j + 0.5) * cw, top - 6, str(c), anchor="middle",
|
|
178
|
+
baseline="alphabetic", size=style.font_size, weight="bold")
|
|
179
|
+
if self.title:
|
|
180
|
+
canvas.raw_text((x0 + x1) / 2, top - 26, self.title, anchor="middle",
|
|
181
|
+
size=style.font_size, weight="bold")
|
|
182
|
+
if self.legend and self.palette: # a shared-palette key; per-column panels label elsewhere
|
|
183
|
+
self._legend(canvas, x0, max(y for _, y in rows) + rh / 2 + 20, style)
|
|
184
|
+
|
|
185
|
+
def _legend(self, canvas, x0, y, style):
|
|
186
|
+
sw, fs = 20.0, style.font_size
|
|
187
|
+
x = x0
|
|
188
|
+
for val, color in self.palette.items():
|
|
189
|
+
canvas.raw_rect(x, y, sw, sw, fill=color, stroke=self.grid, stroke_width=0.9)
|
|
190
|
+
text = self.legend_labels.get(val, val)
|
|
191
|
+
canvas.raw_text(x + sw + 6, y + sw / 2, text, anchor="start", size=fs)
|
|
192
|
+
x += sw + 6 + fs * 0.62 * len(text) + 18
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def heatmap(matrix, **kw) -> Heatmap:
|
|
196
|
+
"""A heatmap panel for a :class:`~genustrator.matrix.Matrix` (genomes × families)."""
|
|
197
|
+
return Heatmap(matrix, **kw)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def states(matrix, **kw) -> States:
|
|
201
|
+
"""A categorical state matrix panel (rows × discrete characters), colours from a value→colour
|
|
202
|
+
``palette`` — for presence–absence or discrete character states beside a tree."""
|
|
203
|
+
return States(matrix, **kw)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def alignment(aln, **kw) -> Alignment:
|
|
207
|
+
"""An alignment panel for an :class:`~genustrator.matrix.Alignment` (genomes × sites)."""
|
|
208
|
+
return Alignment(aln, **kw)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Gene-arrow drawing — the domain drawer (Phylustrator's ``skeleton``, for genomes).
|
|
2
|
+
|
|
3
|
+
Both the base map and the ``genes`` colouring layer draw through :func:`draw_genes`, so a gene is
|
|
4
|
+
drawn one way and every layer follows. ``linear`` draws straight arrows; ``circular`` draws the same
|
|
5
|
+
arrow bent along its ring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def draw_genes(canvas, layout, color, style) -> None:
|
|
14
|
+
"""Draw each gene the layout placed as an arrow pointing along its strand, filled by
|
|
15
|
+
``color(gene)``. Reads ``layout.genes``, so single / stacked / circular all flow through here."""
|
|
16
|
+
if layout.kind == "circular":
|
|
17
|
+
_draw_circular(canvas, layout, color, style)
|
|
18
|
+
else:
|
|
19
|
+
_draw_linear(canvas, layout, color, style)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _draw_linear(canvas, layout, color, style) -> None:
|
|
23
|
+
hh = style.gene_height / 2.0 # half-height, in row-spacing units
|
|
24
|
+
for gene in layout.genes:
|
|
25
|
+
x0, x1, y = layout.box(gene)
|
|
26
|
+
tip = 0.4 * (x1 - x0)
|
|
27
|
+
if gene.strand >= 0:
|
|
28
|
+
pts = [(x0, y - hh), (x1 - tip, y - hh), (x1, y), (x1 - tip, y + hh), (x0, y + hh)]
|
|
29
|
+
else:
|
|
30
|
+
pts = [(x1, y - hh), (x0 + tip, y - hh), (x0, y), (x0 + tip, y + hh), (x1, y + hh)]
|
|
31
|
+
canvas.polygon(pts, fill=color(gene), stroke=style.gene_stroke,
|
|
32
|
+
stroke_width=style.gene_stroke_width)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _polar(a: float, r: float) -> tuple[float, float]:
|
|
36
|
+
return r * math.cos(a), r * math.sin(a)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _arc(a0: float, a1: float, r: float, step: float = 0.12):
|
|
40
|
+
"""Points along the arc from ``a0`` to ``a1`` at radius ``r`` (data coords)."""
|
|
41
|
+
n = max(1, int(math.ceil(abs(a1 - a0) / step)))
|
|
42
|
+
return [_polar(a0 + (a1 - a0) * i / n, r) for i in range(n + 1)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _draw_circular(canvas, layout, color, style) -> None:
|
|
46
|
+
hh = layout.ring_hh
|
|
47
|
+
for gene in layout.genes:
|
|
48
|
+
a0, a1, R = layout.box(gene)
|
|
49
|
+
ri, ro = R - hh, R + hh
|
|
50
|
+
span = a1 - a0
|
|
51
|
+
tip = 0.4 * span # angular length of the arrowhead
|
|
52
|
+
if gene.strand >= 0: # arrow points toward a1
|
|
53
|
+
base = a1 - tip
|
|
54
|
+
pts = _arc(a0, base, ro) + [_polar(a1, R)] + _arc(base, a0, ri)
|
|
55
|
+
else: # arrow points toward a0
|
|
56
|
+
base = a0 + tip
|
|
57
|
+
pts = _arc(base, a1, ro) + _arc(a1, base, ri) + [_polar(a0, R)]
|
|
58
|
+
canvas.polygon(pts, fill=color(gene), stroke=style.gene_stroke,
|
|
59
|
+
stroke_width=style.gene_stroke_width)
|