phylustrator 0.2.3__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.3/src/phylustrator.egg-info → phylustrator-0.2.4}/PKG-INFO +1 -1
  2. {phylustrator-0.2.3 → phylustrator-0.2.4}/pyproject.toml +1 -1
  3. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/figure.py +10 -2
  4. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/guides.py +10 -5
  5. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/highlight.py +4 -3
  6. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layout.py +82 -26
  7. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/track.py +13 -12
  8. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/render.py +6 -4
  9. {phylustrator-0.2.3 → phylustrator-0.2.4/src/phylustrator.egg-info}/PKG-INFO +1 -1
  10. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_genomes.py +40 -0
  11. {phylustrator-0.2.3 → phylustrator-0.2.4}/LICENSE +0 -0
  12. {phylustrator-0.2.3 → phylustrator-0.2.4}/README.md +0 -0
  13. {phylustrator-0.2.3 → phylustrator-0.2.4}/setup.cfg +0 -0
  14. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/__init__.py +0 -0
  15. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/__main__.py +0 -0
  16. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/cli.py +0 -0
  17. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/color.py +0 -0
  18. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/compose.py +0 -0
  19. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/__init__.py +0 -0
  20. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/genome.py +0 -0
  21. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/io.py +0 -0
  22. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/__init__.py +0 -0
  23. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/genes.py +0 -0
  24. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/layers/synteny.py +0 -0
  25. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/matrix.py +0 -0
  26. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/genomes/panels.py +0 -0
  27. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/style.py +0 -0
  28. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/__init__.py +0 -0
  29. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/figure.py +0 -0
  30. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/io.py +0 -0
  31. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/__init__.py +0 -0
  32. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/clades.py +0 -0
  33. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/coloring.py +0 -0
  34. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/events.py +0 -0
  35. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/guides.py +0 -0
  36. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/labels.py +0 -0
  37. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layers/tracks.py +0 -0
  38. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/layout.py +0 -0
  39. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/skeleton.py +0 -0
  40. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/trees/tree.py +0 -0
  41. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator/zombi.py +0 -0
  42. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator.egg-info/SOURCES.txt +0 -0
  43. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator.egg-info/dependency_links.txt +0 -0
  44. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator.egg-info/entry_points.txt +0 -0
  45. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator.egg-info/requires.txt +0 -0
  46. {phylustrator-0.2.3 → phylustrator-0.2.4}/src/phylustrator.egg-info/top_level.txt +0 -0
  47. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_cli.py +0 -0
  48. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_figure.py +0 -0
  49. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_io.py +0 -0
  50. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_layout.py +0 -0
  51. {phylustrator-0.2.3 → phylustrator-0.2.4}/tests/test_tree.py +0 -0
  52. {phylustrator-0.2.3 → 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.3
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.3"
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"
@@ -57,6 +57,14 @@ class Figure:
57
57
  layer(canvas, primary, layout, self.style)
58
58
  return canvas
59
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
+
60
68
  def as_svg(self) -> str:
61
69
  return self._build().as_svg()
62
70
 
@@ -106,8 +114,8 @@ def _draw_base(canvas: Canvas, layout: Layout, style: Style) -> None:
106
114
  if getattr(style, "ring_backbone", True):
107
115
  dash = getattr(style, "gene_style", "arrow") != "wedge" # arrow: dashed loop; wedge: the classic solid one
108
116
  color, width = ("#c9d2ce", 1.2) if dash else ("#d8ddda", 1.4)
109
- for R in layout.rings or []:
110
- 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))
111
119
  else:
112
120
  for y, x0, x1 in layout.backbones:
113
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,41 +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, scale: str = "each",
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
119
  base-proportional arcs (``"nucleotide"``).
101
120
 
102
- ``scale`` decides what a ring's angles mean when a genome has several chromosomes:
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.
103
126
 
104
- ``"each"`` (default) gives every chromosome the whole circle, so gene *order* is comparable ring
105
- to ring and a chromosome's size is not. ``"shared"`` gives every gene the same angular width,
106
- taken from the largest chromosome, so a short chromosome draws a short arc — which is what a
107
- **karyotype** needs. Under ``"each"`` a three-gene chromosome is a full circle of three enormous
108
- wedges beside a seventy-gene ring, and the picture says they are the same size."""
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."""
109
135
  start = math.radians(start_deg)
110
136
  sweep = 2.0 * math.pi - math.radians(break_deg)
111
- boxes, owner, placed, rings, totals = {}, {}, [], [], []
112
- if scale not in ("each", "shared"):
113
- raise ValueError(f"unknown scale {scale!r}; choose 'each' or 'shared'")
114
- widest = max((len(c.genes) for c in genome.chromosomes), default=1) or 1
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
115
159
  for k, chrom in enumerate(genome.chromosomes):
116
- R = 1.0 - band / 2.0 - k * (band + ring_gap)
117
- rings.append(R)
118
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))
119
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.
120
176
  total = float(chrom.length or (chrom.genes[-1].end - chrom.genes[0].start) or 1.0) if nuc \
121
- else float(widest if scale == "shared" else (n or 1))
177
+ else float(reference if (reference and arrange == "concentric") else (n or 1))
122
178
  totals.append(total)
123
179
  # cap the minimum arc so a gene-dense genome (a real GFF) never forces genes to overlap.
124
180
  # The cap counts the ring's own slots: under "shared" that is the widest chromosome, so a
125
181
  # short ring is never inflated back to the width it would have had on its own.
126
182
  min_arc = min(math.radians(min_deg), 0.9 * sweep / max(n if nuc else total, 1.0))
127
- _ = style # (thickness is read from style at draw time via ring_hh below)
128
183
  for rank, gene in enumerate(chrom.genes): # rank, so "ordered" is even with no holes
129
184
  lo_v, hi_v = (float(gene.start), float(gene.end)) if nuc \
130
185
  else (rank + gap / 2.0, rank + 1.0 - gap / 2.0)
@@ -135,14 +190,15 @@ def circular(genome, *, coordinates: str = "ordered", gap: float = 0.16,
135
190
  a0, a1 = mid + min_arc / 2.0, mid - min_arc / 2.0
136
191
  boxes[id(gene)] = (a0, a1, R)
137
192
  owner[id(gene)] = (genome, chrom)
193
+ centres[id(gene)] = (cx, 0.0)
138
194
  placed.append(gene)
139
- gstyle = getattr(style, "gene_style", "arrow") if style is not None else "arrow"
140
- frac = getattr(style, "ring_gene_frac", None) if style is not None else None
141
- if frac is None: # chunky "arrow" vs the classic thin "wedge"
142
- frac = 0.11 if gstyle == "wedge" else 0.30
143
- hh = band * frac # gene half-thickness
144
- outer = (rings[0] if rings else 1.0) + hh
145
- lim = (-outer, outer)
146
- return Layout("circular", boxes, lim, lim, rows=len(genome.chromosomes),
147
- 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,
148
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.3
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
@@ -74,6 +74,46 @@ def test_circular_shared_scale_shortens_the_small_chromosome():
74
74
  circular(g, scale="both")
75
75
 
76
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
+
77
117
  def test_heatmap_panel_beside_tree():
78
118
  tree = tree_plot(loads("(a:1,b:1)R;"))
79
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