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,29 @@
1
+ """Phylustrator — a lean, composable plotter for phylogenetics: **trees** and **genomes**.
2
+
3
+ Two domains, one grammar (``plot(x) + layer + …``), one shared drawing backend:
4
+
5
+ import phylustrator as ph
6
+
7
+ # trees
8
+ tree = ph.trees.loads("((A:1,B:1)C:2,D:3)R;")
9
+ (ph.trees.plot(tree) + ph.trees.color_branches(vals) + ph.trees.time_axis()).save("tree.pdf")
10
+
11
+ # genomes
12
+ G = ph.zombi.read_genomes("run")
13
+ (ph.genomes.plot(G["n12"], layout="circular") + ph.genomes.genes(by="family")).save("ring.png")
14
+
15
+ # bridge: a matrix beside a tree
16
+ ph.beside(ph.trees.plot(tree) + ph.trees.tip_labels(), ph.genomes.heatmap(ph.zombi.read_profiles("run")))
17
+
18
+ ``ph.zombi`` is the only ZOMBI2-format-aware module; ``trees`` / ``genomes`` are general.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from . import genomes, trees, zombi
24
+ from .compose import Composite, beside
25
+ from .style import Style
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = ["trees", "genomes", "zombi", "beside", "Composite", "Style", "__version__"]
@@ -0,0 +1,5 @@
1
+ """``python -m phylustrator`` → the ``phyl`` command-line viewer."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
phylustrator/cli.py ADDED
@@ -0,0 +1,85 @@
1
+ """``phyl`` — a one-shot tree viewer.
2
+
3
+ phyl tree.nwk # render to a temporary PDF and open it
4
+ phyl tree.nwk -o fig.svg # save instead (format from the extension)
5
+ phyl tree.nwk --radial --no-labels
6
+
7
+ Deliberately tiny: it reads a Newick file, draws it, and shows or saves it. Anything richer
8
+ (colouring, tracks, custom styles) lives in the Python API.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ from . import __version__
21
+ from .trees import node_labels, plot, read, scale_bar, tip_labels, time_axis
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ p = argparse.ArgumentParser(prog="phyl", description="Draw a phylogenetic tree from a Newick file.")
26
+ p.add_argument("tree", help="Newick tree file")
27
+ p.add_argument("-o", "--output", metavar="FILE",
28
+ help="save here (.svg/.pdf/.png); default: a temporary PDF, opened")
29
+ p.add_argument("--layout", choices=["rectangular", "radial", "unrooted"], default="rectangular")
30
+ p.add_argument("--radial", action="store_const", const="radial", dest="layout",
31
+ help="shortcut for --layout radial")
32
+ p.add_argument("--unrooted", action="store_const", const="unrooted", dest="layout",
33
+ help="shortcut for --layout unrooted")
34
+ p.add_argument("--no-labels", action="store_true", help="hide tip labels")
35
+ p.add_argument("--node-labels", action="store_true", help="also label internal nodes")
36
+ p.add_argument("--no-stem", action="store_true", help="start at the crown (hide the root stem)")
37
+ p.add_argument("--no-open", action="store_true", help="do not open the temporary file")
38
+ p.add_argument("-V", "--version", action="version", version=f"phyl {__version__}")
39
+ return p
40
+
41
+
42
+ def _open(path: Path) -> None:
43
+ try:
44
+ if sys.platform == "darwin":
45
+ subprocess.run(["open", str(path)], check=False)
46
+ elif sys.platform.startswith("win"):
47
+ os.startfile(str(path)) # type: ignore[attr-defined] # noqa: S606
48
+ else:
49
+ subprocess.run(["xdg-open", str(path)], check=False)
50
+ except OSError:
51
+ pass # opening is a convenience; the path is printed regardless
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = _parser()
56
+ args = parser.parse_args(argv)
57
+
58
+ try:
59
+ tree = read(args.tree)
60
+ except (FileNotFoundError, ValueError) as exc:
61
+ parser.error(str(exc))
62
+
63
+ figure = plot(tree, layout=args.layout, stem=not args.no_stem)
64
+ if not args.no_labels:
65
+ figure = figure + tip_labels()
66
+ if args.node_labels:
67
+ figure = figure + node_labels()
68
+ figure = figure + (time_axis() if args.layout == "rectangular" else scale_bar())
69
+
70
+ if args.output:
71
+ out = figure.save(args.output)
72
+ print(out)
73
+ return 0
74
+
75
+ # No -o: a genuinely throwaway file in the system temp dir.
76
+ tmp = Path(tempfile.mkdtemp(prefix="phyl_")) / "tree.pdf"
77
+ out = figure.save(tmp) # may return a .svg sibling if cairosvg is absent
78
+ print(out)
79
+ if not args.no_open:
80
+ _open(out)
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
phylustrator/color.py ADDED
@@ -0,0 +1,92 @@
1
+ """Colour — colormaps for continuous values, palettes for categories, and normalisation.
2
+
3
+ Matplotlib-free: the viridis ramp is a small embedded lookup table sampled by interpolation, and the
4
+ categorical palette is Paul Tol's colour-blind-safe "bright" set.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import Callable, Iterable
11
+
12
+ # viridis, 16 anchor colours (R, G, B in 0–255), sampled evenly from matplotlib.
13
+ _COLORMAPS: dict[str, list[tuple[int, int, int]]] = {
14
+ "viridis": [
15
+ (68, 1, 84), (72, 26, 108), (71, 47, 125), (65, 68, 135),
16
+ (57, 86, 140), (49, 104, 142), (42, 120, 142), (35, 136, 142),
17
+ (31, 152, 139), (34, 168, 132), (53, 183, 121), (84, 197, 104),
18
+ (122, 209, 81), (165, 219, 54), (210, 226, 27), (253, 231, 37),
19
+ ],
20
+ }
21
+
22
+ # Paul Tol "bright" — distinct and colour-blind-safe.
23
+ _PALETTE = ["#4477AA", "#EE6677", "#228833", "#CCBB44", "#66CCEE", "#AA3377", "#BBBBBB"]
24
+
25
+
26
+ def to_hex(rgb: tuple[float, float, float]) -> str:
27
+ return "#{:02x}{:02x}{:02x}".format(*(int(max(0, min(255, round(c)))) for c in rgb))
28
+
29
+
30
+ def colormap(name: str = "viridis") -> Callable[[float], tuple[int, int, int]]:
31
+ """Return a sampler ``t in [0, 1] -> (R, G, B)`` that interpolates the named colormap."""
32
+ anchors = _colormap_anchors(name)
33
+ n = len(anchors)
34
+
35
+ def sample(t: float) -> tuple[int, int, int]:
36
+ t = max(0.0, min(1.0, float(t)))
37
+ pos = t * (n - 1)
38
+ i = int(math.floor(pos))
39
+ if i >= n - 1:
40
+ return anchors[-1]
41
+ frac = pos - i
42
+ a, b = anchors[i], anchors[i + 1]
43
+ return tuple(a[k] + (b[k] - a[k]) * frac for k in range(3))
44
+
45
+ return sample
46
+
47
+
48
+ def colormap_hex(name: str = "viridis") -> list[str]:
49
+ """The colormap's anchor colours as hex — for a gradient bar."""
50
+ return [to_hex(rgb) for rgb in _colormap_anchors(name)]
51
+
52
+
53
+ def _colormap_anchors(name: str) -> list[tuple[int, int, int]]:
54
+ anchors = _COLORMAPS.get(name.lower())
55
+ if anchors is None:
56
+ raise ValueError(f"unknown colormap {name!r}; available: {sorted(_COLORMAPS)}")
57
+ return anchors
58
+
59
+
60
+ def palette(labels: Iterable) -> dict:
61
+ """A ``{label: hex colour}`` map over ``labels`` (sorted for stability), from the bright set."""
62
+ ordered = sorted(set(labels), key=str)
63
+ return {label: _PALETTE[i % len(_PALETTE)] for i, label in enumerate(ordered)}
64
+
65
+
66
+ def normalize(values: Iterable[float]) -> tuple[float, float, Callable[[float], float]]:
67
+ """Return ``(vmin, vmax, to_unit)`` where ``to_unit(v)`` maps ``[vmin, vmax] -> [0, 1]``."""
68
+ nums = [float(v) for v in values]
69
+ vmin, vmax = min(nums), max(nums)
70
+ span = (vmax - vmin) or 1.0
71
+ return vmin, vmax, lambda v: (float(v) - vmin) / span
72
+
73
+
74
+ def _is_number(v) -> bool:
75
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
76
+
77
+
78
+ def map_values(values: dict, *, cmap: str = "viridis",
79
+ palette: dict | None = None) -> tuple[dict, dict]:
80
+ """Turn ``{key: value}`` into ``({key: hex colour}, scale)``, dispatching on the data: numbers get
81
+ the colormap (and a ``continuous`` scale for a colour bar), labels get a palette (and a
82
+ ``categorical`` scale for a legend). ``scale`` is ``None`` if there is nothing to colour."""
83
+ present = {k: v for k, v in values.items() if v is not None}
84
+ if not present:
85
+ return {}, None
86
+ if all(_is_number(v) for v in present.values()):
87
+ vmin, vmax, to_unit = normalize(present.values())
88
+ sample = colormap(cmap)
89
+ colors = {k: to_hex(sample(to_unit(v))) for k, v in present.items()}
90
+ return colors, {"kind": "continuous", "vmin": vmin, "vmax": vmax, "cmap": cmap}
91
+ pal = palette or globals()["palette"](present.values())
92
+ return {k: pal[v] for k, v in present.items()}, {"kind": "categorical", "palette": pal}
@@ -0,0 +1,61 @@
1
+ """Composition — put a Genustrator panel beside a Phylustrator tree, rows lined up with the tips.
2
+
3
+ ``beside(tree, panel)`` takes a **Phylustrator** figure (the phylogeny — Phylustrator draws it, we do
4
+ not redraw it) and a panel (``heatmap`` / ``alignment``). It asks the tree for its tip pixel positions
5
+ (``Figure.geometry``), renders the tree into the left column, and draws the panel to the right with
6
+ each row at its tip's ``y`` — so a genome's row sits exactly on its leaf.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .render import Canvas
12
+ from .style import Style
13
+
14
+
15
+ class Composite:
16
+ """The rendered composite; save like any figure."""
17
+
18
+ def __init__(self, canvas: Canvas) -> None:
19
+ self._canvas = canvas
20
+
21
+ def as_svg(self) -> str:
22
+ return self._canvas.as_svg()
23
+
24
+ def save(self, path):
25
+ return self._canvas.save(path)
26
+
27
+
28
+ def beside(tree, panel, *, width: float = 1100.0, height: float | None = None,
29
+ tree_fraction: float = 0.4, gap: float = 18.0, pad: float = 34.0,
30
+ footer: float = 0.0, background: str = "white") -> Composite:
31
+ """Render Phylustrator ``tree`` on the left and ``panel`` on the right, rows aligned to the tips.
32
+
33
+ ``tree_fraction`` is the share of the width given to the tree column (the rest, minus ``gap`` and
34
+ ``pad``, holds the panel). ``footer`` reserves blank height below the rows (for a panel's colour
35
+ key). Rows are matched to tips by label and drawn in the tree's tip order. Needs ``cairosvg``."""
36
+ try:
37
+ import cairosvg
38
+ except ImportError as exc: # pragma: no cover
39
+ raise RuntimeError("genustrator.beside needs cairosvg (pip install genustrator[export]) "
40
+ "to place the tree into the composite") from exc
41
+
42
+ n_tips = len(tree.tree.leaves)
43
+ # per-tip row height eases from ~44px (few tips) down to ~24px (many), so 25-40 rows stay sane
44
+ row_px = max(24.0, 46.0 - 0.55 * n_tips)
45
+ H = height if height is not None else max(260.0, 70.0 + row_px * n_tips) + footer
46
+ tree_w = round(width * tree_fraction)
47
+ tree_h = H - footer # tips fill the area above the footer
48
+
49
+ sized = tree.with_size(tree_w, tree_h)
50
+ geom = sized.geometry()
51
+ png = cairosvg.svg2png(bytestring=sized.as_svg().encode(),
52
+ output_width=int(tree_w * 2), output_height=int(tree_h * 2))
53
+
54
+ canvas = Canvas(Style(width=width, height=H, margin=0, background=background), (0.0, 1.0), (0.0, 1.0))
55
+ canvas.embed_png(png, 0, 0, tree_w, tree_h)
56
+
57
+ wanted = set(panel.rows)
58
+ rows = [(t.name, t.y) for t in geom.tips if t.name in wanted]
59
+ if rows:
60
+ panel.draw(canvas, tree_w + gap, width - pad, rows, canvas.style)
61
+ return Composite(canvas)
@@ -0,0 +1,23 @@
1
+ """The **genomes** domain — plot genomes, synteny and alignments. Same grammar as ``trees``:
2
+ ``phylustrator.genomes.plot(genome) + layer + …``
3
+
4
+ import phylustrator as ph
5
+ G = ph.zombi.read_genomes("run") # or ph.genomes.read_gff("genome.gff")
6
+ (ph.genomes.plot(G["n12"], layout="circular") + ph.genomes.genes(by="family")).save("ring.png")
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .figure import Figure, StackFigure, plot, stack
12
+ from .genome import Chromosome, Gene, Genome
13
+ from .io import read_gff
14
+ from .layers import genes, highlight, position_axis, synteny
15
+ from .matrix import Alignment, Matrix
16
+ from .panels import alignment, heatmap, states
17
+
18
+ __all__ = [
19
+ "Gene", "Chromosome", "Genome", "read_gff",
20
+ "plot", "stack", "Figure", "StackFigure",
21
+ "genes", "synteny", "highlight", "position_axis",
22
+ "Matrix", "Alignment", "heatmap", "alignment", "states",
23
+ ]
@@ -0,0 +1,107 @@
1
+ """The figure — ``plot(genome)`` + layers, then render. Mirrors Phylustrator's grammar.
2
+
3
+ fig = plot(genome, layout="linear") + genes(by="family") + position_axis()
4
+ fig.save("map.svg")
5
+
6
+ fig = stack([g1, g2, g3]) + genes() + synteny() # several genomes, one per track
7
+
8
+ A **layer** is a callable ``(canvas, primary, layout, style) -> None`` — the whole extension contract.
9
+ ``primary`` is the genome (or the first, for a stack); layers read the self-describing ``layout``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Callable
15
+
16
+ from ..render import Canvas
17
+ from ..style import Style
18
+ from .layout import Layout, circular, linear, stacked
19
+ from .track import draw_genes
20
+
21
+ Layer = Callable[[Canvas, object, Layout, Style], None]
22
+
23
+ _LAYOUTS = {"linear": linear, "circular": circular}
24
+
25
+
26
+ class Figure:
27
+ def __init__(self, genome, *, layout: str = "linear", coordinates: str = "ordered",
28
+ style: Style | None = None, layers: tuple = ()) -> None:
29
+ if layout not in _LAYOUTS:
30
+ raise ValueError(f"unknown layout {layout!r}; choose from {sorted(_LAYOUTS)}")
31
+ self.genome = genome
32
+ self.layout = layout
33
+ self.coordinates = coordinates
34
+ self.style = style or Style()
35
+ self.layers = tuple(layers)
36
+
37
+ def _make_layout(self) -> Layout:
38
+ return _LAYOUTS[self.layout](self.genome, coordinates=self.coordinates)
39
+
40
+ def _clone(self, **kw) -> "Figure":
41
+ base = dict(layout=self.layout, coordinates=self.coordinates, style=self.style,
42
+ layers=self.layers)
43
+ base.update(kw)
44
+ return Figure(self.genome, **base)
45
+
46
+ def __add__(self, layer: Layer) -> "Figure":
47
+ return self._clone(layers=self.layers + (layer,))
48
+
49
+ def _build(self) -> Canvas:
50
+ layout = self._make_layout()
51
+ canvas = Canvas(self.style, layout.xlim, layout.ylim, equal_aspect=layout.equal_aspect)
52
+ _draw_base(canvas, layout, self.style)
53
+ primary = layout.track_order[0] if layout.track_order else None
54
+ for layer in self.layers:
55
+ layer(canvas, primary, layout, self.style)
56
+ return canvas
57
+
58
+ def as_svg(self) -> str:
59
+ return self._build().as_svg()
60
+
61
+ def save(self, path):
62
+ return self._build().save(path)
63
+
64
+
65
+ class StackFigure(Figure):
66
+ """A figure over several genomes (one track each). Same grammar; a different layout."""
67
+
68
+ def __init__(self, genomes, *, coordinates: str = "ordered", style: Style | None = None,
69
+ layers: tuple = ()) -> None:
70
+ self.genomes = list(genomes)
71
+ self.layout = "stacked"
72
+ self.coordinates = coordinates
73
+ n = len(self.genomes)
74
+ self.style = style or Style(height=70.0 + 82.0 * n, gene_height=0.4)
75
+ self.layers = tuple(layers)
76
+ self.genome = self.genomes[0] if self.genomes else None
77
+
78
+ def _make_layout(self) -> Layout:
79
+ return stacked(self.genomes, coordinates=self.coordinates)
80
+
81
+ def _clone(self, **kw) -> "StackFigure":
82
+ base = dict(coordinates=self.coordinates, style=self.style, layers=self.layers)
83
+ base.update({k: v for k, v in kw.items() if k in ("coordinates", "style", "layers")})
84
+ return StackFigure(self.genomes, **base)
85
+
86
+
87
+ 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)
91
+
92
+
93
+ def stack(genomes, *, coordinates: str = "ordered", style: Style | None = None) -> StackFigure:
94
+ """Start a figure comparing several ``genomes``, one horizontal track each (top genome first)."""
95
+ return StackFigure(genomes, coordinates=coordinates, style=style)
96
+
97
+
98
+ def _draw_base(canvas: Canvas, layout: Layout, style: Style) -> None:
99
+ """The base map: a faint backbone per track, and the gene arrows in the default colour (a
100
+ ``genes`` layer overdraws them coloured)."""
101
+ if layout.kind == "circular":
102
+ for R in layout.rings or []:
103
+ canvas.data_ring(R, "#d8ddda", 1.4)
104
+ else:
105
+ for y, x0, x1 in layout.backbones:
106
+ canvas.line(x0, y, x1, y, "#d8ddda", 1.4)
107
+ draw_genes(canvas, layout, lambda gene: style.default_color, style)
@@ -0,0 +1,41 @@
1
+ """The genome data model — :class:`Gene`, :class:`Chromosome`, :class:`Genome`.
2
+
3
+ Structure only: a genome is chromosomes of ordered genes, and knows nothing about how it is drawn. The
4
+ dataclasses use ``eq=False`` so instances hash by identity (a layout keys its boxes by gene).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass(eq=False)
13
+ class Gene:
14
+ """One gene on a chromosome: its ``family`` (shared across genomes — the unit of colour and
15
+ homology), its ``copy`` name, its ``strand`` (+1 / −1), and its ``position`` (rank order). Optional
16
+ ``start`` / ``end`` carry nucleotide coordinates for the nucleotide resolution."""
17
+
18
+ family: str
19
+ copy: str = ""
20
+ strand: int = 1
21
+ position: int = 0
22
+ start: float | None = None
23
+ end: float | None = None
24
+
25
+
26
+ @dataclass(eq=False)
27
+ class Chromosome:
28
+ id: str
29
+ genes: list = field(default_factory=list)
30
+ topology: str = "linear" # "linear" | "circular"
31
+ length: float | None = None # nucleotide length, if known
32
+
33
+
34
+ @dataclass(eq=False)
35
+ class Genome:
36
+ name: str
37
+ chromosomes: list = field(default_factory=list)
38
+
39
+ @property
40
+ def genes(self) -> list:
41
+ return [g for chrom in self.chromosomes for g in chrom.genes]
@@ -0,0 +1,56 @@
1
+ """General genome input — reading a **GFF3** into :class:`~phylustrator.genomes.genome.Genome`
2
+ objects. Reading a ZOMBI2 *run* (its own file formats) lives in :mod:`phylustrator.zombi`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from .genome import Chromosome, Gene, Genome
10
+
11
+ __all__ = ["read_gff"]
12
+
13
+
14
+ def read_gff(source: str | Path, *, feature: str = "gene", name_attr: str = "locus_tag") -> dict:
15
+ """Read a **GFF3** into ``{seqid: Genome}`` — real genes at their real base coordinates.
16
+
17
+ Genes are the rows whose type is ``feature`` (default ``"gene"``); each gets its ``start`` / ``end``
18
+ (bp), ``strand``, and a family/name from ``name_attr`` in column 9 (falling back to ``Name`` / ``ID``).
19
+ A ``##sequence-region`` line or a ``region`` feature sets the replicon length and circularity."""
20
+ path = Path(source)
21
+ per: dict[str, dict] = {}
22
+ for raw in path.read_text().splitlines():
23
+ if raw.startswith("##sequence-region"):
24
+ p = raw.split()
25
+ if len(p) >= 4:
26
+ per.setdefault(p[1], _blank())["length"] = float(p[3])
27
+ continue
28
+ if raw.startswith("#") or not raw.strip():
29
+ continue
30
+ c = raw.split("\t")
31
+ if len(c) < 9:
32
+ continue
33
+ seqid, _src, ftype, start, end, _score, strand, _phase, attrs = c[:9]
34
+ info = per.setdefault(seqid, _blank())
35
+ if ftype == "region" and "Is_circular=true" in attrs:
36
+ info["circular"] = True
37
+ if ftype != feature:
38
+ continue
39
+ a = dict(kv.split("=", 1) for kv in attrs.split(";") if "=" in kv)
40
+ fam = a.get(name_attr) or a.get("Name") or a.get("ID") or ""
41
+ info["genes"].append(Gene(family=fam, strand=(-1 if strand == "-" else 1),
42
+ start=float(start), end=float(end)))
43
+ info["length"] = max(info["length"], float(end))
44
+ genomes = {}
45
+ for seqid, info in per.items():
46
+ genes = sorted(info["genes"], key=lambda g: g.start)
47
+ for rank, g in enumerate(genes):
48
+ g.position = rank
49
+ topo = "circular" if info["circular"] else "linear"
50
+ genomes[seqid] = Genome(name=seqid, chromosomes=[
51
+ Chromosome(id=seqid, genes=genes, topology=topo, length=info["length"])])
52
+ return genomes
53
+
54
+
55
+ def _blank() -> dict:
56
+ return {"genes": [], "length": 0.0, "circular": False}
@@ -0,0 +1,8 @@
1
+ """Layers — the composable decorations added to a genome figure with ``+``."""
2
+
3
+ from .genes import genes
4
+ from .guides import position_axis
5
+ from .highlight import highlight
6
+ from .synteny import synteny
7
+
8
+ __all__ = ["genes", "position_axis", "synteny", "highlight"]
@@ -0,0 +1,35 @@
1
+ """The gene-colouring layer — paint the arrows by an attribute (default the gene family).
2
+
3
+ Families get consistent colours across genomes (family "5" is the same colour everywhere), which is
4
+ what makes stacked genomes read as synteny.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ...color import colormap, to_hex
10
+ from ..track import draw_genes
11
+
12
+
13
+ def _key(value: str):
14
+ return (0, int(value)) if str(value).lstrip("-").isdigit() else (1, str(value))
15
+
16
+
17
+ def genes(by: str = "family", *, cmap: str = "viridis", palette: dict | None = None):
18
+ """Colour gene arrows by ``by`` (an attribute of each gene). Returns a layer."""
19
+
20
+ def layer(canvas, primary, layout, style):
21
+ keys = sorted({str(getattr(g, by)) for g in layout.genes}, key=_key)
22
+ if palette is not None:
23
+ color_of = dict(palette)
24
+ else:
25
+ sample = colormap(cmap)
26
+ n = len(keys)
27
+ color_of = {k: to_hex(sample(i / (n - 1) if n > 1 else 0.5)) for i, k in enumerate(keys)}
28
+ canvas.scale = {"kind": "genes", "colors": color_of, "by": by}
29
+
30
+ def color(gene):
31
+ return color_of.get(str(getattr(gene, by)), style.default_color)
32
+
33
+ draw_genes(canvas, layout, color, style)
34
+
35
+ return layer
@@ -0,0 +1,78 @@
1
+ """Guide layers — the chrome that reads the coordinates.
2
+
3
+ ``position_axis`` adapts to the layout: a horizontal ruler for ``linear``, and an inner coordinate
4
+ **ring** (base-position ticks around the circle) for ``circular`` — the standard reference on a
5
+ circular genome map.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+
13
+ def position_axis(label: str = "position", *, ticks: int = 6):
14
+ """A coordinate axis in the layout's units: a horizontal ruler (``linear``) or an inner ring of
15
+ base-position ticks (``circular``)."""
16
+
17
+ def layer(canvas, primary, layout, style):
18
+ if layout.kind == "circular":
19
+ _circular(canvas, layout, style)
20
+ else:
21
+ _linear(canvas, layout, style, label, ticks)
22
+
23
+ return layer
24
+
25
+
26
+ def _linear(canvas, layout, style, label, ticks) -> None:
27
+ _, height = canvas.size
28
+ m = style.margin
29
+ x0, x1 = layout.xlim
30
+ y = height - m * 0.62
31
+ canvas.raw_line(canvas.px(x0), y, canvas.px(x1), y, "#333333", 1.2)
32
+ small = style.font_size * 0.85
33
+ for i in range(ticks):
34
+ t = x0 + (x1 - x0) * i / (ticks - 1)
35
+ tx = canvas.px(t)
36
+ canvas.raw_line(tx, y, tx, y + 5, "#333333", 1.2)
37
+ canvas.raw_text(tx, y + 12, f"{t:.0f}", anchor="middle", size=small)
38
+ if label:
39
+ mid = (canvas.px(x0) + canvas.px(x1)) / 2
40
+ canvas.raw_text(mid, y + 24, label, anchor="middle", size=style.font_size)
41
+
42
+
43
+ def _circular(canvas, layout, style) -> None:
44
+ total = layout.totals[0] if layout.totals else 1.0
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
48
+ step = _nice_step(total, 8)
49
+ small = style.font_size * 0.9
50
+ v = 0.0
51
+ while v < total - step * 1e-6:
52
+ 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)
56
+ canvas.text(lx, ly, _fmt_bp(v), anchor="middle", size=small)
57
+ v += step
58
+
59
+
60
+ def _nice_step(span: float, target: int) -> float:
61
+ raw = span / max(target, 1)
62
+ if raw <= 0:
63
+ return 1.0
64
+ exp = math.floor(math.log10(raw))
65
+ base = raw / 10 ** exp
66
+ nice = 1 if base < 1.5 else 2 if base < 3.5 else 5 if base < 7.5 else 10
67
+ return nice * 10 ** exp
68
+
69
+
70
+ def _fmt_bp(v: float) -> str:
71
+ v = int(round(v))
72
+ if v == 0:
73
+ return "0"
74
+ if v >= 1_000_000:
75
+ return f"{v // 1_000_000} Mb" if v % 1_000_000 == 0 else f"{v / 1_000_000:.1f} Mb"
76
+ if v >= 1_000:
77
+ return f"{v // 1_000} kb" if v % 1_000 == 0 else f"{v / 1_000:.0f} kb"
78
+ return str(v)
@@ -0,0 +1,38 @@
1
+ """The highlight layer — mark a span of a genome (a rearranged segment, a region of interest).
2
+
3
+ Shades the ranks ``[start, end]`` of one genome's chromosome with a tinted, outlined band that the
4
+ gene arrows read through. Works on a single ``plot`` or on one track of a ``stack``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def highlight(genome, chromosome=None, start: int = 0, end: int = 0, *,
11
+ color: str = "#e8a33d", label: str | None = None, pad: float = 0.12):
12
+ """Highlight positions ``start``..``end`` (inclusive) on ``genome`` / ``chromosome``.
13
+
14
+ ``chromosome`` may be a :class:`Chromosome`, its ``id``, or ``None`` (any chromosome)."""
15
+
16
+ def _matches(owner):
17
+ g, chrom = owner
18
+ if g is not genome:
19
+ return False
20
+ return chromosome is None or chrom is chromosome or chrom.id == chromosome
21
+
22
+ def layer(canvas, primary, layout, style):
23
+ sel = [gene for gene in layout.genes
24
+ if _matches(layout.owner[id(gene)]) and start <= gene.position <= end]
25
+ if not sel:
26
+ return
27
+ boxes = [layout.box(g) for g in sel]
28
+ x0 = min(b[0] for b in boxes)
29
+ x1 = max(b[1] for b in boxes)
30
+ y = boxes[0][2]
31
+ hh = style.gene_height / 2.0 + pad
32
+ canvas.region(x0, y - hh, x1, y + hh, fill=color, opacity=0.2,
33
+ stroke=color, stroke_width=1.4, rx=4.0)
34
+ if label:
35
+ canvas.text((x0 + x1) / 2.0, y - hh, label, dy=-6, anchor="middle",
36
+ color=color, size=style.font_size)
37
+
38
+ return layer