phylustrator 0.2.2__tar.gz → 0.2.4__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.2/src/phylustrator.egg-info → phylustrator-0.2.4}/PKG-INFO +1 -1
- {phylustrator-0.2.2 → phylustrator-0.2.4}/pyproject.toml +1 -1
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/figure.py +20 -8
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/guides.py +10 -5
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/highlight.py +4 -3
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layout.py +89 -20
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/track.py +13 -12
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/render.py +6 -4
- {phylustrator-0.2.2 → phylustrator-0.2.4/src/phylustrator.egg-info}/PKG-INFO +1 -1
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_genomes.py +57 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/LICENSE +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/README.md +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/setup.cfg +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/__init__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/__main__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/cli.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/color.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/compose.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/__init__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/genome.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/io.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/__init__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/genes.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/synteny.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/matrix.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/panels.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/style.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/__init__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/figure.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/io.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/__init__.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/clades.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/coloring.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/events.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/guides.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/labels.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/tracks.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layout.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/skeleton.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/tree.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/zombi.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/SOURCES.txt +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/dependency_links.txt +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/entry_points.txt +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/requires.txt +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/top_level.txt +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_cli.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_figure.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_io.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_layout.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_tree.py +0 -0
- {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_version.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phylustrator"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
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"
|
|
@@ -25,7 +25,7 @@ _LAYOUTS = {"linear": linear, "circular": circular}
|
|
|
25
25
|
|
|
26
26
|
class Figure:
|
|
27
27
|
def __init__(self, genome, *, layout: str = "linear", coordinates: str = "ordered",
|
|
28
|
-
style: Style | None = None, layers: tuple = ()) -> None:
|
|
28
|
+
style: Style | None = None, layers: tuple = (), **layout_kw) -> None:
|
|
29
29
|
if layout not in _LAYOUTS:
|
|
30
30
|
raise ValueError(f"unknown layout {layout!r}; choose from {sorted(_LAYOUTS)}")
|
|
31
31
|
self.genome = genome
|
|
@@ -33,13 +33,15 @@ class Figure:
|
|
|
33
33
|
self.coordinates = coordinates
|
|
34
34
|
self.style = style or Style()
|
|
35
35
|
self.layers = tuple(layers)
|
|
36
|
+
self.layout_kw = dict(layout_kw) # e.g. scale="shared" on a circular genome
|
|
36
37
|
|
|
37
38
|
def _make_layout(self) -> Layout:
|
|
38
|
-
return _LAYOUTS[self.layout](self.genome, coordinates=self.coordinates, style=self.style
|
|
39
|
+
return _LAYOUTS[self.layout](self.genome, coordinates=self.coordinates, style=self.style,
|
|
40
|
+
**self.layout_kw)
|
|
39
41
|
|
|
40
42
|
def _clone(self, **kw) -> "Figure":
|
|
41
43
|
base = dict(layout=self.layout, coordinates=self.coordinates, style=self.style,
|
|
42
|
-
layers=self.layers)
|
|
44
|
+
layers=self.layers, **self.layout_kw)
|
|
43
45
|
base.update(kw)
|
|
44
46
|
return Figure(self.genome, **base) # type: ignore[arg-type] # kw dict, params are typed
|
|
45
47
|
|
|
@@ -55,6 +57,14 @@ class Figure:
|
|
|
55
57
|
layer(canvas, primary, layout, self.style)
|
|
56
58
|
return canvas
|
|
57
59
|
|
|
60
|
+
def extent(self) -> tuple[tuple[float, float], tuple[float, float]]:
|
|
61
|
+
"""The ``(xlim, ylim)`` the canvas fits to, in layout units — the figure's shape before any
|
|
62
|
+
page size is chosen. Sizing several canvases from this puts their figures on one absolute
|
|
63
|
+
scale, which is what comparing genomes side by side needs: a genome half the size of another
|
|
64
|
+
should be drawn half the size, not blown up to fill its own square."""
|
|
65
|
+
layout = self._make_layout()
|
|
66
|
+
return layout.xlim, layout.ylim
|
|
67
|
+
|
|
58
68
|
def as_svg(self) -> str:
|
|
59
69
|
return self._build().as_svg()
|
|
60
70
|
|
|
@@ -85,9 +95,11 @@ class StackFigure(Figure):
|
|
|
85
95
|
|
|
86
96
|
|
|
87
97
|
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
|
-
|
|
98
|
+
style: Style | None = None, **layout_kw) -> Figure:
|
|
99
|
+
"""Start a figure for one ``genome``. Add layers with ``+``, then :meth:`Figure.save`.
|
|
100
|
+
|
|
101
|
+
Extra keywords reach the layout — ``scale="shared"`` on a circular genome, for one."""
|
|
102
|
+
return Figure(genome, layout=layout, coordinates=coordinates, style=style, **layout_kw)
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
def stack(genomes, *, coordinates: str = "ordered", style: Style | None = None) -> StackFigure:
|
|
@@ -102,8 +114,8 @@ def _draw_base(canvas: Canvas, layout: Layout, style: Style) -> None:
|
|
|
102
114
|
if getattr(style, "ring_backbone", True):
|
|
103
115
|
dash = getattr(style, "gene_style", "arrow") != "wedge" # arrow: dashed loop; wedge: the classic solid one
|
|
104
116
|
color, width = ("#c9d2ce", 1.2) if dash else ("#d8ddda", 1.4)
|
|
105
|
-
for R in layout.rings or []:
|
|
106
|
-
canvas.data_ring(R, color, width, dash=dash)
|
|
117
|
+
for k, R in enumerate(layout.rings or []):
|
|
118
|
+
canvas.data_ring(R, color, width, dash=dash, centre=layout.ring_centre(k))
|
|
107
119
|
else:
|
|
108
120
|
for y, x0, x1 in layout.backbones:
|
|
109
121
|
canvas.line(x0, y, x1, y, "#d8ddda", 1.4)
|
|
@@ -43,16 +43,21 @@ def _linear(canvas, layout, style, label, ticks) -> None:
|
|
|
43
43
|
def _circular(canvas, layout, style) -> None:
|
|
44
44
|
total = layout.totals[0] if layout.totals else 1.0
|
|
45
45
|
start, sweep = layout.angle_start, layout.angle_sweep
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
# the axis sits inside the innermost circle — and on *its* centre, which a row of circles moves
|
|
47
|
+
k = min(range(len(layout.rings)), key=lambda i: layout.rings[i]) if layout.rings else 0
|
|
48
|
+
R0 = layout.rings[k] if layout.rings else 0.85
|
|
49
|
+
cx, cy = layout.ring_centre(k)
|
|
50
|
+
inner = R0 - layout.half_height(R0) - 0.06
|
|
51
|
+
canvas.data_ring(inner, "#c7d0cc", 1.0, centre=(cx, cy)) # the coordinate ring
|
|
48
52
|
step = _nice_step(total, 8)
|
|
49
53
|
small = style.font_size * 0.9
|
|
50
54
|
v = 0.0
|
|
51
55
|
while v < total - step * 1e-6:
|
|
52
56
|
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),
|
|
55
|
-
|
|
57
|
+
canvas.line(cx + inner * math.cos(a), cy + inner * math.sin(a),
|
|
58
|
+
cx + (inner - 0.03) * math.cos(a), cy + (inner - 0.03) * math.sin(a),
|
|
59
|
+
"#5a6763", 1.1)
|
|
60
|
+
lx, ly = cx + (inner - 0.10) * math.cos(a), cy + (inner - 0.10) * math.sin(a)
|
|
56
61
|
canvas.text(lx, ly, _fmt_bp(v), anchor="middle", size=small)
|
|
57
62
|
v += step
|
|
58
63
|
|
|
@@ -33,11 +33,12 @@ def highlight(genome, chromosome=None, start: int = 0, end: int = 0, *,
|
|
|
33
33
|
a_lo = min(min(b[0], b[1]) for b in boxes)
|
|
34
34
|
a_hi = max(max(b[0], b[1]) for b in boxes)
|
|
35
35
|
R = boxes[0][2]
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
c = layout.centre(sel[0]) # the circle this chromosome is drawn on
|
|
37
|
+
hh = layout.half_height(R) * 1.9 + pad # a halo a touch wider than the genes
|
|
38
|
+
band = _arc(a_lo, a_hi, R + hh, c=c) + _arc(a_hi, a_lo, R - hh, c=c)
|
|
38
39
|
canvas.polygon(band, fill=color, opacity=0.55, stroke=color, stroke_width=1.2)
|
|
39
40
|
if label:
|
|
40
|
-
mx, my = _polar((a_lo + a_hi) / 2.0, R + hh + 0.06)
|
|
41
|
+
mx, my = _polar((a_lo + a_hi) / 2.0, R + hh + 0.06, c)
|
|
41
42
|
canvas.text(mx, my, label, anchor="middle", color=color, size=style.font_size)
|
|
42
43
|
return
|
|
43
44
|
x0 = min(b[0] for b in boxes)
|
|
@@ -29,7 +29,10 @@ class Layout:
|
|
|
29
29
|
backbones: list = field(default_factory=list) # [(y, x0, x1)] faint tracks (linear/stacked)
|
|
30
30
|
track_order: list = field(default_factory=list) # genomes top->bottom (synteny adjacency)
|
|
31
31
|
rings: list | None = None # circular: centre radius per chromosome
|
|
32
|
+
ring_centres: list = field(default_factory=list) # circular: (cx, cy) per ring; empty = origin
|
|
32
33
|
ring_hh: float = 0.0 # circular: gene half-height, radius units
|
|
34
|
+
ring_hh_rel: float | None = None # circular: if set, half-height is this × R
|
|
35
|
+
centres: dict = field(default_factory=dict) # circular: id(gene) -> (cx, cy); absent = origin
|
|
33
36
|
equal_aspect: bool = False # circular keeps the rings round
|
|
34
37
|
angle_start: float = 0.0 # circular: angle (rad) of position 0
|
|
35
38
|
angle_sweep: float = 0.0 # circular: angular span (rad) of a chromosome
|
|
@@ -38,6 +41,21 @@ class Layout:
|
|
|
38
41
|
def box(self, gene):
|
|
39
42
|
return self.boxes[id(gene)]
|
|
40
43
|
|
|
44
|
+
def centre(self, gene) -> tuple[float, float]:
|
|
45
|
+
"""The circle a gene is drawn on. Concentric rings all sit on the origin; a row of circles
|
|
46
|
+
gives each chromosome its own centre."""
|
|
47
|
+
cx, cy = self.centres.get(id(gene), (0.0, 0.0))
|
|
48
|
+
return float(cx), float(cy)
|
|
49
|
+
|
|
50
|
+
def half_height(self, R: float) -> float:
|
|
51
|
+
"""Gene half-thickness at radius ``R``. Concentric rings share one thickness, since they are
|
|
52
|
+
all much the same size. A row scales it with the circle, so a three-gene chromosome beside a
|
|
53
|
+
hundred-gene one is drawn as a small circle rather than as a blob."""
|
|
54
|
+
return self.ring_hh if self.ring_hh_rel is None else self.ring_hh_rel * R
|
|
55
|
+
|
|
56
|
+
def ring_centre(self, k: int) -> tuple[float, float]:
|
|
57
|
+
return self.ring_centres[k] if self.ring_centres else (0.0, 0.0)
|
|
58
|
+
|
|
41
59
|
|
|
42
60
|
def linear(genome, *, coordinates: str = "ordered", gap: float = 0.16, style=None) -> Layout:
|
|
43
61
|
"""Genes on one horizontal track per chromosome."""
|
|
@@ -90,28 +108,78 @@ def stacked(genomes, *, coordinates: str = "ordered", gap: float = 0.16,
|
|
|
90
108
|
|
|
91
109
|
|
|
92
110
|
def circular(genome, *, coordinates: str = "ordered", gap: float = 0.16,
|
|
93
|
-
start_deg: float = 90.0, break_deg: float = 0.0,
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
start_deg: float = 90.0, break_deg: float = 0.0, scale: str | float = "each",
|
|
112
|
+
arrange: str = "concentric", band: float = 0.34, ring_gap: float = 0.10,
|
|
113
|
+
row_gap: float = 0.22, min_deg: float = 2.2, style=None) -> Layout:
|
|
114
|
+
"""Genes wrapped onto a ring, one ring per chromosome (chromosome 0 outermost / leftmost).
|
|
96
115
|
|
|
97
116
|
Angles sweep **clockwise** from the top. By default the ring is closed (``break_deg=0``) so genes
|
|
98
117
|
are evenly spaced all the way round; set ``break_deg`` to leave a wedge marking a linear
|
|
99
118
|
chromosome's ends. ``coordinates`` chooses equal angular slots by **rank** (``"ordered"``) or
|
|
100
|
-
base-proportional arcs (``"nucleotide"``).
|
|
119
|
+
base-proportional arcs (``"nucleotide"``).
|
|
120
|
+
|
|
121
|
+
``arrange`` places the chromosomes of a multi-chromosome genome:
|
|
122
|
+
|
|
123
|
+
``"concentric"`` (default) nests them as rings about one centre — the classic single-genome map.
|
|
124
|
+
``"row"`` gives each chromosome its own circle, left to right: a **karyotype**, where the
|
|
125
|
+
chromosomes are separate objects rather than tracks of one map.
|
|
126
|
+
|
|
127
|
+
``scale`` decides what a chromosome's size means. ``"each"`` (default) draws every chromosome the
|
|
128
|
+
same: a whole circle concentric, or an equal-sized circle in a row — gene *order* is then
|
|
129
|
+
comparable chromosome to chromosome and size is not. ``"shared"`` draws them to one scale, taken
|
|
130
|
+
from the largest: in a row the radius follows the gene count, so a gene takes the same arc
|
|
131
|
+
*length* in every circle; concentric, a short chromosome draws a short arc rather than a full
|
|
132
|
+
circle of enormous wedges. Pass a **number** instead to fix that reference explicitly — the same
|
|
133
|
+
value across several figures puts them all on one scale, which is what comparing the karyotypes
|
|
134
|
+
of different genomes needs."""
|
|
101
135
|
start = math.radians(start_deg)
|
|
102
136
|
sweep = 2.0 * math.pi - math.radians(break_deg)
|
|
103
|
-
boxes, owner, placed, rings, totals = {}, {}, [], [], []
|
|
137
|
+
boxes, owner, placed, rings, totals, centres, ring_centres = {}, {}, [], [], [], {}, []
|
|
138
|
+
if arrange not in ("concentric", "row"):
|
|
139
|
+
raise ValueError(f"unknown arrange {arrange!r}; choose 'concentric' or 'row'")
|
|
140
|
+
sizes = [len(c.genes) for c in genome.chromosomes]
|
|
141
|
+
if isinstance(scale, str):
|
|
142
|
+
if scale not in ("each", "shared"):
|
|
143
|
+
raise ValueError(f"unknown scale {scale!r}; choose 'each', 'shared', or a number")
|
|
144
|
+
reference = float(max(sizes, default=1) or 1) if scale == "shared" else 0.0
|
|
145
|
+
else:
|
|
146
|
+
reference = float(scale)
|
|
147
|
+
if reference <= 0:
|
|
148
|
+
raise ValueError(f"scale must be a positive number of genes, not {scale!r}")
|
|
149
|
+
gstyle = getattr(style, "gene_style", "arrow") if style is not None else "arrow"
|
|
150
|
+
frac = getattr(style, "ring_gene_frac", None) if style is not None else None
|
|
151
|
+
if frac is None: # chunky "arrow" vs the classic thin "wedge"
|
|
152
|
+
frac = 0.11 if gstyle == "wedge" else 0.30
|
|
153
|
+
hh = band * frac # gene half-thickness
|
|
154
|
+
# A row sizes each circle by its own gene count, so the thickness has to follow the circle:
|
|
155
|
+
# one absolute thickness would swallow the small chromosomes whole.
|
|
156
|
+
rel = hh / max(1.0 - band / 2.0, 1e-9) # thickness as a fraction of the circle
|
|
157
|
+
hh_rel = rel if arrange == "row" else None
|
|
158
|
+
cursor = 0.0
|
|
104
159
|
for k, chrom in enumerate(genome.chromosomes):
|
|
105
|
-
R = 1.0 - band / 2.0 - k * (band + ring_gap)
|
|
106
|
-
rings.append(R)
|
|
107
160
|
n = len(chrom.genes)
|
|
161
|
+
if arrange == "row":
|
|
162
|
+
R = (n / reference) if reference else 1.0
|
|
163
|
+
R = max(R, 1e-3)
|
|
164
|
+
rings.append(R)
|
|
165
|
+
cx = cursor + R * (1.0 + rel)
|
|
166
|
+
cursor = cx + R * (1.0 + rel) + row_gap
|
|
167
|
+
ring_centres.append((cx, 0.0))
|
|
168
|
+
else:
|
|
169
|
+
R = 1.0 - band / 2.0 - k * (band + ring_gap)
|
|
170
|
+
rings.append(R)
|
|
171
|
+
cx = 0.0
|
|
172
|
+
ring_centres.append((0.0, 0.0))
|
|
108
173
|
nuc = coordinates == "nucleotide" and n and chrom.genes[0].start is not None
|
|
174
|
+
# concentric + a reference: the chromosome takes the fraction of the circle it is worth.
|
|
175
|
+
# A row already says that with the radius, so its angles always run all the way round.
|
|
109
176
|
total = float(chrom.length or (chrom.genes[-1].end - chrom.genes[0].start) or 1.0) if nuc \
|
|
110
|
-
else float(n or 1)
|
|
177
|
+
else float(reference if (reference and arrange == "concentric") else (n or 1))
|
|
111
178
|
totals.append(total)
|
|
112
|
-
# cap the minimum arc so a gene-dense genome (a real GFF) never forces genes to overlap
|
|
113
|
-
|
|
114
|
-
|
|
179
|
+
# cap the minimum arc so a gene-dense genome (a real GFF) never forces genes to overlap.
|
|
180
|
+
# The cap counts the ring's own slots: under "shared" that is the widest chromosome, so a
|
|
181
|
+
# short ring is never inflated back to the width it would have had on its own.
|
|
182
|
+
min_arc = min(math.radians(min_deg), 0.9 * sweep / max(n if nuc else total, 1.0))
|
|
115
183
|
for rank, gene in enumerate(chrom.genes): # rank, so "ordered" is even with no holes
|
|
116
184
|
lo_v, hi_v = (float(gene.start), float(gene.end)) if nuc \
|
|
117
185
|
else (rank + gap / 2.0, rank + 1.0 - gap / 2.0)
|
|
@@ -122,14 +190,15 @@ def circular(genome, *, coordinates: str = "ordered", gap: float = 0.16,
|
|
|
122
190
|
a0, a1 = mid + min_arc / 2.0, mid - min_arc / 2.0
|
|
123
191
|
boxes[id(gene)] = (a0, a1, R)
|
|
124
192
|
owner[id(gene)] = (genome, chrom)
|
|
193
|
+
centres[id(gene)] = (cx, 0.0)
|
|
125
194
|
placed.append(gene)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
195
|
+
if arrange == "row":
|
|
196
|
+
tall = max(((R * (1.0 + rel)) for R in rings), default=1.0)
|
|
197
|
+
xlim, ylim = (0.0, max(cursor - row_gap, 1e-6)), (-tall, tall)
|
|
198
|
+
else:
|
|
199
|
+
outer = (rings[0] if rings else 1.0) + hh
|
|
200
|
+
xlim = ylim = (-outer, outer)
|
|
201
|
+
return Layout("circular", boxes, xlim, ylim, rows=len(genome.chromosomes),
|
|
202
|
+
genes=placed, owner=owner, rings=rings, ring_centres=ring_centres,
|
|
203
|
+
ring_hh=hh, ring_hh_rel=hh_rel, centres=centres, equal_aspect=True,
|
|
135
204
|
track_order=[genome], angle_start=start, angle_sweep=sweep, totals=totals)
|
|
@@ -32,24 +32,25 @@ def _draw_linear(canvas, layout, color, style) -> None:
|
|
|
32
32
|
stroke_width=style.gene_stroke_width)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def _polar(a: float, r: float) -> tuple[float, float]:
|
|
36
|
-
return r * math.cos(a), r * math.sin(a)
|
|
35
|
+
def _polar(a: float, r: float, c: tuple[float, float] = (0.0, 0.0)) -> tuple[float, float]:
|
|
36
|
+
return c[0] + r * math.cos(a), c[1] + r * math.sin(a)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def _arc(a0: float, a1: float, r: float, step: float = 0.12):
|
|
39
|
+
def _arc(a0: float, a1: float, r: float, step: float = 0.12, c: tuple[float, float] = (0.0, 0.0)):
|
|
40
40
|
"""Points along the arc from ``a0`` to ``a1`` at radius ``r`` (data coords)."""
|
|
41
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)]
|
|
42
|
+
return [_polar(a0 + (a1 - a0) * i / n, r, c) for i in range(n + 1)]
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _draw_circular(canvas, layout, color, style) -> None:
|
|
46
46
|
"""Each gene an arrow bent along its ring. ``gene_style="arrow"`` (default) is a chunky body with a
|
|
47
47
|
flared arrowhead (head wider than the body, tapering to a point — the beautiful genome look);
|
|
48
48
|
``"wedge"`` is the thin, un-flared shape."""
|
|
49
|
-
hh = layout.ring_hh
|
|
50
49
|
chunky = getattr(style, "gene_style", "arrow") != "wedge"
|
|
51
50
|
for gene in layout.genes:
|
|
52
51
|
a0, a1, R = layout.box(gene)
|
|
52
|
+
c = layout.centre(gene) # a row of circles gives each chromosome its own
|
|
53
|
+
hh = layout.half_height(R)
|
|
53
54
|
ri, ro = R - hh, R + hh
|
|
54
55
|
span = a1 - a0
|
|
55
56
|
tip = min(0.45 * span, math.radians(11.0)) # arrowhead angular length (capped for long genes)
|
|
@@ -58,14 +59,14 @@ def _draw_circular(canvas, layout, color, style) -> None:
|
|
|
58
59
|
head_hh = max(hh, min(hh * 1.5, R * tip)) if chunky else hh
|
|
59
60
|
if gene.strand >= 0: # arrow points toward a1
|
|
60
61
|
base = a1 - tip
|
|
61
|
-
pts = (_arc(a0, base, ro)
|
|
62
|
-
+ [_polar(base, R + head_hh), _polar(a1, R), _polar(base, R - head_hh)]
|
|
63
|
-
+ _arc(base, a0, ri))
|
|
62
|
+
pts = (_arc(a0, base, ro, c=c)
|
|
63
|
+
+ [_polar(base, R + head_hh, c), _polar(a1, R, c), _polar(base, R - head_hh, c)]
|
|
64
|
+
+ _arc(base, a0, ri, c=c))
|
|
64
65
|
else: # arrow points toward a0
|
|
65
66
|
base = a0 + tip
|
|
66
|
-
pts = ([_polar(a0, R), _polar(base, R + head_hh)]
|
|
67
|
-
+ _arc(base, a1, ro)
|
|
68
|
-
+ _arc(a1, base, ri)
|
|
69
|
-
+ [_polar(base, R - head_hh)])
|
|
67
|
+
pts = ([_polar(a0, R, c), _polar(base, R + head_hh, c)]
|
|
68
|
+
+ _arc(base, a1, ro, c=c)
|
|
69
|
+
+ _arc(a1, base, ri, c=c)
|
|
70
|
+
+ [_polar(base, R - head_hh, c)])
|
|
70
71
|
canvas.polygon(pts, fill=color(gene), stroke=style.gene_stroke,
|
|
71
72
|
stroke_width=style.gene_stroke_width)
|
|
@@ -148,10 +148,12 @@ class Canvas:
|
|
|
148
148
|
p.Z()
|
|
149
149
|
self._d.append(p)
|
|
150
150
|
|
|
151
|
-
def data_ring(self, r: float, color: str, width: float, *, dash: bool = False
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
151
|
+
def data_ring(self, r: float, color: str, width: float, *, dash: bool = False,
|
|
152
|
+
centre: tuple[float, float] = (0.0, 0.0)) -> None:
|
|
153
|
+
"""A circle of *data* radius ``r`` (a chromosome backbone / ruler), on the data origin unless
|
|
154
|
+
``centre`` moves it — a karyotype draws each chromosome on its own centre."""
|
|
155
|
+
cx, cy = self.px(centre[0]), self.py(centre[1])
|
|
156
|
+
rpx = self.px(centre[0] + r) - cx
|
|
155
157
|
extra = {"stroke_dasharray": "5,4"} if dash else {}
|
|
156
158
|
self._d.append(draw.Circle(cx, cy, abs(rpx), fill="none", stroke=color,
|
|
157
159
|
stroke_width=width, **extra))
|
|
@@ -22,6 +22,7 @@ from phylustrator.genomes import (
|
|
|
22
22
|
synteny,
|
|
23
23
|
tracks,
|
|
24
24
|
)
|
|
25
|
+
from phylustrator.genomes.layout import circular
|
|
25
26
|
from phylustrator.trees import loads
|
|
26
27
|
from phylustrator.trees import plot as tree_plot
|
|
27
28
|
|
|
@@ -57,6 +58,62 @@ def test_position_axis_on_nucleotide_ring():
|
|
|
57
58
|
assert svg.lstrip().startswith("<")
|
|
58
59
|
|
|
59
60
|
|
|
61
|
+
def test_circular_shared_scale_shortens_the_small_chromosome():
|
|
62
|
+
"""A karyotype: under ``scale="shared"`` a short chromosome draws a short arc."""
|
|
63
|
+
big = _genome("g", list("12345678")).chromosomes[0]
|
|
64
|
+
small = Chromosome(id="c2", genes=_genome("s", ["9", "10"]).chromosomes[0].genes,
|
|
65
|
+
topology="circular", length=200)
|
|
66
|
+
g = Genome(name="k", chromosomes=[big, small])
|
|
67
|
+
arc = {}
|
|
68
|
+
for scale in ("each", "shared"):
|
|
69
|
+
lay = circular(g, scale=scale)
|
|
70
|
+
a0, a1, _ = lay.boxes[id(small.genes[0])]
|
|
71
|
+
arc[scale] = abs(a1 - a0)
|
|
72
|
+
assert arc["shared"] < arc["each"] / 3 # 8 slots wide, not 2
|
|
73
|
+
with pytest.raises(ValueError, match="unknown scale"):
|
|
74
|
+
circular(g, scale="both")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _karyotype():
|
|
78
|
+
big = _genome("g", list("12345678")).chromosomes[0]
|
|
79
|
+
small = Chromosome(id="c2", genes=_genome("s", ["9", "10"]).chromosomes[0].genes,
|
|
80
|
+
topology="circular", length=200)
|
|
81
|
+
return Genome(name="k", chromosomes=[big, small]), big, small
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_circular_row_puts_each_chromosome_on_its_own_circle():
|
|
85
|
+
"""A karyotype: ``arrange="row"`` gives every chromosome a centre of its own, left to right."""
|
|
86
|
+
g, big, small = _karyotype()
|
|
87
|
+
lay = circular(g, arrange="row")
|
|
88
|
+
cx = [lay.centre(c.genes[0])[0] for c in (big, small)]
|
|
89
|
+
assert cx[0] < cx[1] # chromosome 0 leftmost
|
|
90
|
+
assert len(set(lay.ring_centres)) == 2
|
|
91
|
+
assert lay.xlim[1] - lay.xlim[0] > lay.ylim[1] - lay.ylim[0] # a row is wider than it is tall
|
|
92
|
+
assert (plot(g, layout="circular", arrange="row") + genes(by="family")).as_svg().startswith("<")
|
|
93
|
+
with pytest.raises(ValueError, match="unknown arrange"):
|
|
94
|
+
circular(g, arrange="stack")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_circular_row_scale_shared_sizes_the_circle_by_gene_count():
|
|
98
|
+
"""Under ``scale="shared"`` the radius follows the gene count, so the thickness must follow it
|
|
99
|
+
too — one absolute thickness would draw the short chromosome as a blob."""
|
|
100
|
+
g, big, small = _karyotype()
|
|
101
|
+
each, shared = circular(g, arrange="row"), circular(g, arrange="row", scale="shared")
|
|
102
|
+
assert each.rings[0] == each.rings[1] # every circle the same
|
|
103
|
+
assert shared.rings[1] == pytest.approx(shared.rings[0] * 2 / 8)
|
|
104
|
+
assert shared.half_height(shared.rings[1]) < shared.half_height(shared.rings[0])
|
|
105
|
+
# a number fixes the reference explicitly, so two genomes can be drawn to one scale
|
|
106
|
+
assert circular(g, arrange="row", scale=16).rings[0] == pytest.approx(0.5)
|
|
107
|
+
with pytest.raises(ValueError, match="positive number"):
|
|
108
|
+
circular(g, scale=0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_extent_reports_the_layout_the_canvas_fits_to():
|
|
112
|
+
g, _, _ = _karyotype()
|
|
113
|
+
fig = plot(g, layout="circular", arrange="row")
|
|
114
|
+
assert fig.extent() == (circular(g, arrange="row").xlim, circular(g, arrange="row").ylim)
|
|
115
|
+
|
|
116
|
+
|
|
60
117
|
def test_heatmap_panel_beside_tree():
|
|
61
118
|
tree = tree_plot(loads("(a:1,b:1)R;"))
|
|
62
119
|
m = Matrix(rows=["a", "b"], cols=["f1", "f2", "f3"], values=[[1, 0, 2], [0, 1, 1]])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|