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.
Files changed (52) hide show
  1. {phylustrator-0.2.2/src/phylustrator.egg-info → phylustrator-0.2.4}/PKG-INFO +1 -1
  2. {phylustrator-0.2.2 → phylustrator-0.2.4}/pyproject.toml +1 -1
  3. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/figure.py +20 -8
  4. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/guides.py +10 -5
  5. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/highlight.py +4 -3
  6. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layout.py +89 -20
  7. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/track.py +13 -12
  8. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/render.py +6 -4
  9. {phylustrator-0.2.2 → phylustrator-0.2.4/src/phylustrator.egg-info}/PKG-INFO +1 -1
  10. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_genomes.py +57 -0
  11. {phylustrator-0.2.2 → phylustrator-0.2.4}/LICENSE +0 -0
  12. {phylustrator-0.2.2 → phylustrator-0.2.4}/README.md +0 -0
  13. {phylustrator-0.2.2 → phylustrator-0.2.4}/setup.cfg +0 -0
  14. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/__init__.py +0 -0
  15. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/__main__.py +0 -0
  16. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/cli.py +0 -0
  17. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/color.py +0 -0
  18. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/compose.py +0 -0
  19. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/__init__.py +0 -0
  20. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/genome.py +0 -0
  21. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/io.py +0 -0
  22. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/__init__.py +0 -0
  23. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/genes.py +0 -0
  24. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/synteny.py +0 -0
  25. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/matrix.py +0 -0
  26. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/genomes/panels.py +0 -0
  27. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/style.py +0 -0
  28. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/__init__.py +0 -0
  29. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/figure.py +0 -0
  30. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/io.py +0 -0
  31. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/__init__.py +0 -0
  32. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/clades.py +0 -0
  33. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/coloring.py +0 -0
  34. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/events.py +0 -0
  35. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/guides.py +0 -0
  36. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/labels.py +0 -0
  37. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layers/tracks.py +0 -0
  38. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/layout.py +0 -0
  39. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/skeleton.py +0 -0
  40. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/trees/tree.py +0 -0
  41. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator/zombi.py +0 -0
  42. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/SOURCES.txt +0 -0
  43. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/dependency_links.txt +0 -0
  44. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/entry_points.txt +0 -0
  45. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/requires.txt +0 -0
  46. {phylustrator-0.2.2 → phylustrator-0.2.4}/src/phylustrator.egg-info/top_level.txt +0 -0
  47. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_cli.py +0 -0
  48. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_figure.py +0 -0
  49. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_io.py +0 -0
  50. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_layout.py +0 -0
  51. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_tree.py +0 -0
  52. {phylustrator-0.2.2 → phylustrator-0.2.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
5
5
  Author-email: "Adrian A. Davin" <aaredav@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phylustrator"
7
- version = "0.2.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
- return Figure(genome, layout=layout, coordinates=coordinates, style=style)
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
- 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
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), "#5a6763", 1.1)
55
- lx, ly = (inner - 0.10) * math.cos(a), (inner - 0.10) * math.sin(a)
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
- hh = layout.ring_hh * 1.9 + pad # a halo a touch wider than the genes
37
- band = _arc(a_lo, a_hi, R + hh) + _arc(a_hi, a_lo, R - hh)
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
- band: float = 0.34, ring_gap: float = 0.10, min_deg: float = 2.2, style=None) -> Layout:
95
- """Genes wrapped onto a ring, one concentric ring per chromosome (chromosome 0 outermost).
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
- min_arc = min(math.radians(min_deg), 0.9 * sweep / max(n, 1))
114
- _ = style # (thickness is read from style at draw time via ring_hh below)
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
- gstyle = getattr(style, "gene_style", "arrow") if style is not None else "arrow"
127
- frac = getattr(style, "ring_gene_frac", None) if style is not None else None
128
- if frac is None: # chunky "arrow" vs the classic thin "wedge"
129
- frac = 0.11 if gstyle == "wedge" else 0.30
130
- hh = band * frac # gene half-thickness
131
- outer = (rings[0] if rings else 1.0) + hh
132
- lim = (-outer, outer)
133
- return Layout("circular", boxes, lim, lim, rows=len(genome.chromosomes),
134
- genes=placed, owner=owner, rings=rings, ring_hh=hh, equal_aspect=True,
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) -> None:
152
- """A circle of *data* radius ``r`` centred on the data origin (a chromosome backbone / ruler)."""
153
- cx, cy = self.px(0.0), self.py(0.0)
154
- rpx = self.px(r) - cx
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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phylustrator
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
5
5
  Author-email: "Adrian A. Davin" <aaredav@gmail.com>
6
6
  License: MIT
@@ -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