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.
Files changed (40) hide show
  1. phylustrator/__init__.py +29 -0
  2. phylustrator/__main__.py +5 -0
  3. phylustrator/cli.py +85 -0
  4. phylustrator/color.py +92 -0
  5. phylustrator/compose.py +61 -0
  6. phylustrator/genomes/__init__.py +23 -0
  7. phylustrator/genomes/figure.py +107 -0
  8. phylustrator/genomes/genome.py +41 -0
  9. phylustrator/genomes/io.py +56 -0
  10. phylustrator/genomes/layers/__init__.py +8 -0
  11. phylustrator/genomes/layers/genes.py +35 -0
  12. phylustrator/genomes/layers/guides.py +78 -0
  13. phylustrator/genomes/layers/highlight.py +38 -0
  14. phylustrator/genomes/layers/synteny.py +56 -0
  15. phylustrator/genomes/layout.py +130 -0
  16. phylustrator/genomes/matrix.py +32 -0
  17. phylustrator/genomes/panels.py +208 -0
  18. phylustrator/genomes/track.py +59 -0
  19. phylustrator/render.py +229 -0
  20. phylustrator/style.py +29 -0
  21. phylustrator/trees/__init__.py +23 -0
  22. phylustrator/trees/figure.py +122 -0
  23. phylustrator/trees/io.py +148 -0
  24. phylustrator/trees/layers/__init__.py +30 -0
  25. phylustrator/trees/layers/clades.py +35 -0
  26. phylustrator/trees/layers/coloring.py +143 -0
  27. phylustrator/trees/layers/events.py +96 -0
  28. phylustrator/trees/layers/guides.py +149 -0
  29. phylustrator/trees/layers/labels.py +49 -0
  30. phylustrator/trees/layers/tracks.py +36 -0
  31. phylustrator/trees/layout.py +136 -0
  32. phylustrator/trees/skeleton.py +90 -0
  33. phylustrator/trees/tree.py +90 -0
  34. phylustrator/zombi.py +145 -0
  35. phylustrator-0.1.0.dist-info/METADATA +132 -0
  36. phylustrator-0.1.0.dist-info/RECORD +40 -0
  37. phylustrator-0.1.0.dist-info/WHEEL +5 -0
  38. phylustrator-0.1.0.dist-info/entry_points.txt +2 -0
  39. phylustrator-0.1.0.dist-info/licenses/LICENSE +21 -0
  40. 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)