plot-io 0.12.0__py3-none-win_arm64.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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Genomic regions: parsing what ``region=`` takes, and the unit of coordinate labels."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from decimal import Decimal, InvalidOperation
|
|
7
|
+
from typing import Any, NamedTuple
|
|
8
|
+
|
|
9
|
+
__all__ = ["Region", "Regions", "Chromosome", "parse_region", "parse_regions", "parse_bp",
|
|
10
|
+
"auto_unit", "whole", "pending", "UNITS"]
|
|
11
|
+
|
|
12
|
+
#: Coordinate units and their size in bp.
|
|
13
|
+
UNITS = {"bp": 1, "kb": 1_000, "Mb": 1_000_000, "Gb": 1_000_000_000}
|
|
14
|
+
|
|
15
|
+
_POS = r"[\d.,_]+(?:[eE]\d+)?\s*(?:[kKmMgG][bB]?|[bB][pP])?"
|
|
16
|
+
_STRING = re.compile(rf"^\s*([^:\s]+)\s*:\s*({_POS})\s*-\s*({_POS})\s*$")
|
|
17
|
+
_SIZE = re.compile(r"^([\d.,_]+(?:[eE]\d+)?)\s*([kKmMgG][bB]?|[bB][pP])?$")
|
|
18
|
+
_SUFFIX = {"": 1, "bp": 1, "k": 1_000, "kb": 1_000, "m": 1_000_000, "mb": 1_000_000,
|
|
19
|
+
"g": 1_000_000_000, "gb": 1_000_000_000}
|
|
20
|
+
_NAME = re.compile(r"^[^:\s,]+$")
|
|
21
|
+
_THOUSANDS = re.compile(r"^\d{1,3}(?:,\d{3})+$")
|
|
22
|
+
_CHR_KEYS = ("chr", "chrom", "chromosome", "chr_id", "seqname")
|
|
23
|
+
_START_KEYS = ("start", "chromStart", "chrom_start")
|
|
24
|
+
_END_KEYS = ("end", "chromEnd", "chrom_end")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Region(NamedTuple):
|
|
28
|
+
"""A region: 0-based, half-open."""
|
|
29
|
+
|
|
30
|
+
chr: str
|
|
31
|
+
start: int
|
|
32
|
+
end: int
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def span(self) -> int:
|
|
36
|
+
return self.end - self.start
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
return f"{self.chr}:{self.start + 1:,}-{self.end:,}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Chromosome(NamedTuple):
|
|
43
|
+
"""A whole chromosome (``"chr1"``): a region whose end, the chromosome's size, is known
|
|
44
|
+
once the figure is drawn (see :func:`whole`)."""
|
|
45
|
+
|
|
46
|
+
chr: str
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return self.chr
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_bp(v: Any, what: str = "position", positive: bool = False) -> int:
|
|
53
|
+
"""A position or length in bp: a whole number, or a string with digit separators
|
|
54
|
+
(``"1,200,000"``, ``"1_200_000"``) and an optional unit (``"1.2M"``, ``"1.2Mb"``,
|
|
55
|
+
``"500k"``, ``"500kb"``, ``"1200bp"``; ``k``, ``M``, ``G``, case-insensitive)."""
|
|
56
|
+
if isinstance(v, str):
|
|
57
|
+
m = _SIZE.match(v.strip())
|
|
58
|
+
if m is None:
|
|
59
|
+
raise ValueError(f"{what} must be a number of bp (\"1,200,000\", \"1.2M\", "
|
|
60
|
+
f"\"500kb\"), got {v!r}")
|
|
61
|
+
digits, unit = m.group(1), (m.group(2) or "").lower()
|
|
62
|
+
if "." in digits and "," in digits:
|
|
63
|
+
raise ValueError(f"{what}: write {v!r} without thousands separators")
|
|
64
|
+
if "," in digits and not _THOUSANDS.match(digits.split("e")[0].split("E")[0]):
|
|
65
|
+
raise ValueError(f"{what}: the commas of {v!r} are not thousands separators "
|
|
66
|
+
f"(\"1,500,000\"); a decimal is written with a point "
|
|
67
|
+
f"(\"1.5M\")")
|
|
68
|
+
try:
|
|
69
|
+
d = Decimal(digits.replace(",", "").replace("_", "")) * _SUFFIX[unit]
|
|
70
|
+
except InvalidOperation:
|
|
71
|
+
raise ValueError(f"{what} must be a number of bp, got {v!r}") from None
|
|
72
|
+
if d != d.to_integral_value():
|
|
73
|
+
raise ValueError(f"{what} must be a whole number of bp, got {v!r}")
|
|
74
|
+
n = int(d)
|
|
75
|
+
else:
|
|
76
|
+
try:
|
|
77
|
+
f = float(v)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
raise ValueError(f"{what} must be a number, got {v!r}") from None
|
|
80
|
+
if not math.isfinite(f) or f != int(f):
|
|
81
|
+
raise ValueError(f"{what} must be a whole number, got {v!r}")
|
|
82
|
+
n = int(f)
|
|
83
|
+
if positive and n <= 0:
|
|
84
|
+
raise ValueError(f"{what} must be positive, got {v!r}")
|
|
85
|
+
return n
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _int(v: Any, what: str) -> int:
|
|
89
|
+
return parse_bp(v, f"region {what}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _pick(m: Mapping[str, Any], keys: tuple[str, ...]) -> Any:
|
|
93
|
+
for k in keys:
|
|
94
|
+
if k in m:
|
|
95
|
+
v = m[k]
|
|
96
|
+
if not isinstance(v, (str, int, float)) and hasattr(v, "__len__"):
|
|
97
|
+
if len(v) != 1:
|
|
98
|
+
raise ValueError("a region mapping holds one region")
|
|
99
|
+
v = v[0]
|
|
100
|
+
return v
|
|
101
|
+
raise ValueError(f"a region mapping needs one of {', '.join(map(repr, keys))}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_region(v: Any, whole: bool = False) -> Region:
|
|
105
|
+
"""A region from a string, 1-based and inclusive (``"chr2:143,120,001-143,780,000"``;
|
|
106
|
+
positions with units: ``"chr1:1.2M-1.5M"``, ``"chr1:500kb-2Mb"``, see
|
|
107
|
+
:func:`parse_bp`), a ``(chr, start, end)`` tuple or list, 0-based and half-open, or a
|
|
108
|
+
mapping with ``chr``, ``start`` and ``end``. A region has no strand: one given (a
|
|
109
|
+
fourth element, a ``strand`` key) is an error. ``whole``: a chromosome name alone
|
|
110
|
+
(``"chr1"``) is the whole chromosome, a :class:`Chromosome`."""
|
|
111
|
+
if isinstance(v, Region):
|
|
112
|
+
return v
|
|
113
|
+
if whole and isinstance(v, Chromosome):
|
|
114
|
+
return v # type: ignore[return-value]
|
|
115
|
+
if whole and isinstance(v, str) and _NAME.match(v.strip()):
|
|
116
|
+
return Chromosome(v.strip()) # type: ignore[return-value]
|
|
117
|
+
if isinstance(v, str):
|
|
118
|
+
m = _STRING.match(v)
|
|
119
|
+
if m is None:
|
|
120
|
+
raise ValueError(f"expected 'chr:start-end' (1-based, inclusive), got {v!r}")
|
|
121
|
+
chr_, start, end = m.group(1), _int(m.group(2), "start") - 1, _int(m.group(3), "end")
|
|
122
|
+
if start < 0 or end <= start:
|
|
123
|
+
raise ValueError(f"a region needs 1 <= start <= end (1-based, inclusive), got "
|
|
124
|
+
f"{start + 1:,}-{end:,} in {v!r}")
|
|
125
|
+
elif isinstance(v, Mapping):
|
|
126
|
+
if "strand" in v:
|
|
127
|
+
raise ValueError(f"a region has no strand (drawn the same either way), got {v!r}")
|
|
128
|
+
chr_ = str(_pick(v, _CHR_KEYS))
|
|
129
|
+
start, end = _int(_pick(v, _START_KEYS), "start"), _int(_pick(v, _END_KEYS), "end")
|
|
130
|
+
elif isinstance(v, (tuple, list)) and len(v) == 4:
|
|
131
|
+
raise ValueError(f"a region has no strand (drawn the same either way): give "
|
|
132
|
+
f"(chr, start, end), got {v!r}")
|
|
133
|
+
elif isinstance(v, (tuple, list)) and len(v) == 3:
|
|
134
|
+
chr_, start, end = str(v[0]), _int(v[1], "start"), _int(v[2], "end")
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError(f"expected 'chr:start-end', (chr, start, end) or a mapping, "
|
|
137
|
+
f"got {v!r}")
|
|
138
|
+
if start < 0 or end <= start:
|
|
139
|
+
raise ValueError(f"a region needs 0 <= start < end, got {start}-{end}")
|
|
140
|
+
return Region(chr_, start, end)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class Regions(tuple):
|
|
144
|
+
"""Several regions drawn side by side (``region=[r1, r2]``), in order."""
|
|
145
|
+
|
|
146
|
+
def __str__(self) -> str:
|
|
147
|
+
return ", ".join(str(r) for r in self)
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Regions({', '.join(repr(str(r)) for r in self)})"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _one(v: Any) -> bool:
|
|
154
|
+
"""Whether an element of a list can only be a region of its own (not a field of one)."""
|
|
155
|
+
return isinstance(v, (Region, Chromosome, Mapping, tuple, list)) or (
|
|
156
|
+
isinstance(v, str) and _STRING.match(v) is not None)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _names(v: Any) -> bool:
|
|
160
|
+
"""Whether a list is of chromosome names (``["chr1", "chr2"]``), not ``(chr, start,
|
|
161
|
+
end)`` with positions as strings."""
|
|
162
|
+
if not all(isinstance(e, str) and _NAME.match(e.strip()) for e in v):
|
|
163
|
+
return False
|
|
164
|
+
for e in v[1:]:
|
|
165
|
+
try:
|
|
166
|
+
parse_bp(e)
|
|
167
|
+
except ValueError:
|
|
168
|
+
continue
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_regions(v: Any) -> Region | Regions:
|
|
174
|
+
"""A region (as :func:`parse_region` takes it, a chromosome name included) or several,
|
|
175
|
+
a list of regions or chromosome names (as :class:`Regions`; a list of one is that
|
|
176
|
+
region)."""
|
|
177
|
+
if isinstance(v, Regions):
|
|
178
|
+
return v
|
|
179
|
+
if isinstance(v, (tuple, list)) and not isinstance(v, (Region, Chromosome)) and v \
|
|
180
|
+
and (all(_one(e) for e in v) or _names(v)):
|
|
181
|
+
got = [parse_region(e, whole=True) for e in v]
|
|
182
|
+
return got[0] if len(got) == 1 else Regions(got)
|
|
183
|
+
return parse_region(v, whole=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def pending(v: Any) -> bool:
|
|
187
|
+
"""Whether a region value (a region, several, ``None``) holds a whole chromosome whose
|
|
188
|
+
size is not known yet."""
|
|
189
|
+
return isinstance(v, Chromosome) or (isinstance(v, Regions) and
|
|
190
|
+
any(isinstance(r, Chromosome) for r in v))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def whole(v: Any, sizes: Any) -> Any:
|
|
194
|
+
"""``v`` (a region value) with each :class:`Chromosome` made the region of the whole
|
|
195
|
+
chromosome; ``sizes()`` gives the chromosome sizes known (a mapping), called once and
|
|
196
|
+
only when needed. A chromosome without a size raises naming ``chr_sizes``."""
|
|
197
|
+
if not pending(v):
|
|
198
|
+
return v
|
|
199
|
+
known = sizes()
|
|
200
|
+
|
|
201
|
+
def one(r: Any) -> Region:
|
|
202
|
+
if not isinstance(r, Chromosome):
|
|
203
|
+
return r
|
|
204
|
+
n = known.get(r.chr)
|
|
205
|
+
if n is None:
|
|
206
|
+
raise ValueError(f"the size of {r.chr!r} is unknown: give the x axis chr_sizes "
|
|
207
|
+
f"(a mapping of chromosome sizes, gwseq_io.get_chr_sizes(…)) or "
|
|
208
|
+
f"draw a reader that has it")
|
|
209
|
+
return Region(r.chr, 0, int(n))
|
|
210
|
+
return Regions(one(r) for r in v) if isinstance(v, Regions) else one(v)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def auto_unit(lo: float, hi: float) -> str:
|
|
214
|
+
"""The unit coordinate labels from ``lo`` to ``hi`` (bp, drawn values) are written in:
|
|
215
|
+
with the ticks about a fifth of the span apart, the largest unit in which they are at
|
|
216
|
+
least 1 apart and at most 1000; else the largest in which they are at most 1000 and
|
|
217
|
+
need at most 3 decimals; else bp."""
|
|
218
|
+
step = (hi - lo) / 5 or 1.0
|
|
219
|
+
big = max(abs(lo), abs(hi))
|
|
220
|
+
for name in ("Gb", "Mb", "kb", "bp"):
|
|
221
|
+
u = UNITS[name]
|
|
222
|
+
if step >= u and big <= 1000 * u:
|
|
223
|
+
return name
|
|
224
|
+
for name in ("Gb", "Mb", "kb"):
|
|
225
|
+
u = UNITS[name]
|
|
226
|
+
if big <= 1000 * u and step >= 0.001 * u:
|
|
227
|
+
return name
|
|
228
|
+
return "bp"
|
plot_io/genome/scale.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""The genome scale of an axis (``scale="genome"``): linear in bp, its limits the axis'
|
|
2
|
+
``region`` less its ``origin``, without padding, tick labels in bp, kb or Mb (``unit``,
|
|
3
|
+
``unit_in``) or as ``chr:pos`` (``tick_format="chr:pos"``), and the chromosome as the
|
|
4
|
+
automatic label. Genome series set it on their x
|
|
5
|
+
axis when it has no scale of its own.
|
|
6
|
+
|
|
7
|
+
An axis over several regions (``region=[r1, r2]``) draws them side by side as segments
|
|
8
|
+
(:class:`Segments`): each ``region_widths="span"`` wide (its share of the bp) or
|
|
9
|
+
``"equal"``, ``region_gap`` apart, with its own ticks and chromosome label. Its drawn
|
|
10
|
+
units run from 0 at the first segment's start; a segment draws its region's positions
|
|
11
|
+
through :meth:`Segment.u`.
|
|
12
|
+
|
|
13
|
+
With ``scale_bar``, an x genome axis draws a scale bar in place of its ticks and tick
|
|
14
|
+
labels: a bar at its right end (at each segment's, over several regions) with its length
|
|
15
|
+
written left of it (``10 kb``); the label is the chromosome without a unit."""
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
from typing import Any, NamedTuple
|
|
19
|
+
|
|
20
|
+
from ..numbers import NumberStyle, _decimals, _fixed
|
|
21
|
+
from .regions import UNITS, Region, Regions, auto_unit, parse_bp
|
|
22
|
+
|
|
23
|
+
__all__ = ["GenomeFormat", "CHR_POS", "check_tick_format", "origin_of", "axis_unit",
|
|
24
|
+
"axis_label", "Segment", "Segments", "segments_of", "segment_ticks",
|
|
25
|
+
"segment_labels", "check_scale_bar", "scale_bar_on", "bar_length", "bar_text",
|
|
26
|
+
"scale_bars"]
|
|
27
|
+
|
|
28
|
+
#: The ``tick_format`` writing genome positions as ``chr2:143,250,000``.
|
|
29
|
+
CHR_POS = "chr:pos"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_tick_format(v: Any) -> Any:
|
|
33
|
+
"""An axis ``tick_format``: a number format, or ``"chr:pos"`` (genome axes)."""
|
|
34
|
+
if isinstance(v, str) and v.strip().lower() == CHR_POS:
|
|
35
|
+
return CHR_POS
|
|
36
|
+
from ..numbers import check_format
|
|
37
|
+
return check_format(v)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GenomeFormat:
|
|
41
|
+
"""Tick labels of genome coordinates (drawn bp) in ``unit``: numbers alone
|
|
42
|
+
(``unit_in="label"``) or with the unit (``"ticks"``: ``500kb``; 0 stays ``0``); bp
|
|
43
|
+
with ``,`` between thousands unless the style has its own separator. With
|
|
44
|
+
``chr``, positions as ``chr:pos``: the chromosome, then the position (the drawn value
|
|
45
|
+
plus ``origin``) in bp with ``,`` between thousands (``chr2:143,250,000``), or in kb
|
|
46
|
+
or Mb with the unit (``chr2:143.25Mb``)."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, unit: str, unit_in: str, chr: str | None = None,
|
|
49
|
+
origin: float = 0.0) -> None:
|
|
50
|
+
self.unit = unit
|
|
51
|
+
self.unit_in = unit_in
|
|
52
|
+
self.chr = chr
|
|
53
|
+
self.origin = origin
|
|
54
|
+
|
|
55
|
+
def format_all(self, values: list[float], style: NumberStyle) -> list[Any]:
|
|
56
|
+
u = UNITS[self.unit]
|
|
57
|
+
if self.chr is not None:
|
|
58
|
+
return self._positions(values, u, style)
|
|
59
|
+
if u == 1 and not style.thousands: # bp: "," between thousands
|
|
60
|
+
style = copy.copy(style)
|
|
61
|
+
style.thousands = ","
|
|
62
|
+
scaled = [v / u for v in values]
|
|
63
|
+
d = _decimals(scaled, None, cap=6)
|
|
64
|
+
out = []
|
|
65
|
+
for v in scaled:
|
|
66
|
+
text = _fixed(v, d)
|
|
67
|
+
if self.unit_in == "ticks" and float(text) == 0:
|
|
68
|
+
s = "0"
|
|
69
|
+
else:
|
|
70
|
+
s = style.digits(text) + (self.unit if self.unit_in == "ticks" else "")
|
|
71
|
+
out.append(style.wrap(s))
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
def _positions(self, values: list[float], u: int, style: NumberStyle) -> list[Any]:
|
|
75
|
+
scaled = [(v + self.origin) / u for v in values]
|
|
76
|
+
d = _decimals(scaled, None, cap=6)
|
|
77
|
+
out = []
|
|
78
|
+
for v in scaled:
|
|
79
|
+
text = _fixed(v, d)
|
|
80
|
+
if u == 1:
|
|
81
|
+
whole, _, frac = text.partition(".")
|
|
82
|
+
sign = "-" if whole.startswith("-") else ""
|
|
83
|
+
whole = f"{int(whole.lstrip('-')):,}"
|
|
84
|
+
text = sign + whole + ("." + frac if frac else "")
|
|
85
|
+
out.append(f"{self.chr}:{text}")
|
|
86
|
+
else:
|
|
87
|
+
out.append(f"{self.chr}:{style.digits(text)}{self.unit}")
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
def __call__(self, v: float) -> Any:
|
|
91
|
+
return self.format_all([v], NumberStyle())[0]
|
|
92
|
+
|
|
93
|
+
def _key(self) -> tuple[Any, ...]:
|
|
94
|
+
return (self.unit, self.unit_in, self.chr, self.origin)
|
|
95
|
+
|
|
96
|
+
def __eq__(self, other: Any) -> bool:
|
|
97
|
+
return isinstance(other, GenomeFormat) and self._key() == other._key()
|
|
98
|
+
|
|
99
|
+
def __hash__(self) -> int:
|
|
100
|
+
return hash(self._key())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def origin_of(origin: Any, region: Region | None) -> float | None:
|
|
104
|
+
"""The position an axis ``origin`` names on a genome axis over ``region`` (``center``,
|
|
105
|
+
``start``, ``end`` of the region, or a position); ``None`` for an anchor without a
|
|
106
|
+
region."""
|
|
107
|
+
if origin is None:
|
|
108
|
+
return 0.0
|
|
109
|
+
if isinstance(origin, str):
|
|
110
|
+
if region is None:
|
|
111
|
+
return None
|
|
112
|
+
return {"center": (region.start + region.end) / 2, "start": float(region.start),
|
|
113
|
+
"end": float(region.end)}[origin]
|
|
114
|
+
return float(origin)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def axis_unit(axis: Any, lo: float, hi: float) -> str:
|
|
118
|
+
"""The axis' ``unit``; ``auto``: from its limits (drawn bp), bp for ``chr:pos``
|
|
119
|
+
labels."""
|
|
120
|
+
u = axis.resolve("unit")
|
|
121
|
+
if u != "auto":
|
|
122
|
+
return u
|
|
123
|
+
return "bp" if axis.resolve("tick_format") == CHR_POS else auto_unit(lo, hi)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tick_format(state: Any) -> Any:
|
|
127
|
+
"""The ``GenomeFormat`` of a genome axis whose ``tick_format`` is ``auto`` or
|
|
128
|
+
``chr:pos``; ``None`` for other formats."""
|
|
129
|
+
axis = state.axis
|
|
130
|
+
fmt = axis.resolve("tick_format")
|
|
131
|
+
if fmt not in ("auto", CHR_POS):
|
|
132
|
+
return None
|
|
133
|
+
unit = axis_unit(axis, state.lo, state.hi)
|
|
134
|
+
if fmt == CHR_POS:
|
|
135
|
+
if state.region is None:
|
|
136
|
+
raise ValueError("tick_format=\"chr:pos\" needs the axis' region")
|
|
137
|
+
return GenomeFormat(unit, "ticks", state.region.chr, state.origin)
|
|
138
|
+
return GenomeFormat(unit, axis.resolve("unit_in"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def axis_label(state: Any, given: Any) -> Any:
|
|
142
|
+
"""The label of a genome axis: ``given`` (a string's ``{chr}``, ``{unit}``,
|
|
143
|
+
``{start}``, ``{end}`` filled in), else the chromosome, with the unit unless the tick
|
|
144
|
+
labels show it; ``None`` without a region."""
|
|
145
|
+
region = state.region
|
|
146
|
+
axis = state.axis
|
|
147
|
+
unit = axis_unit(axis, state.lo, state.hi)
|
|
148
|
+
if given is None:
|
|
149
|
+
if region is None or axis.resolve("tick_format") == CHR_POS:
|
|
150
|
+
return None
|
|
151
|
+
given = "{chr} ({unit})" if axis.resolve("unit_in") == "label" \
|
|
152
|
+
and not scale_bar_on(state) else "{chr}"
|
|
153
|
+
if not isinstance(given, str) or "{" not in given:
|
|
154
|
+
return given
|
|
155
|
+
if region is None:
|
|
156
|
+
return given
|
|
157
|
+
return given.format(chr=region.chr, unit=unit, start=region.start + 1, end=region.end)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Segment(NamedTuple):
|
|
161
|
+
"""A region of an axis over several: drawn from ``u0`` (drawn units), ``k`` drawn units
|
|
162
|
+
per bp, ``px`` wide on axes as wide as the segments were made for, its ``number`` (from 0)."""
|
|
163
|
+
|
|
164
|
+
region: Region
|
|
165
|
+
u0: float
|
|
166
|
+
k: float
|
|
167
|
+
px: float
|
|
168
|
+
number: int
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def u1(self) -> float:
|
|
172
|
+
return self.u0 + self.region.span * self.k
|
|
173
|
+
|
|
174
|
+
def u(self, pos: float) -> float:
|
|
175
|
+
"""Drawn position of ``pos`` (bp)."""
|
|
176
|
+
return self.u0 + (pos - self.region.start) * self.k
|
|
177
|
+
|
|
178
|
+
def holds(self, chr: str, pos: float) -> bool:
|
|
179
|
+
return chr == self.region.chr and self.region.start <= pos <= self.region.end
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Segments(tuple):
|
|
183
|
+
"""The segments of an axis over several regions, in order; ``total``: the drawn units
|
|
184
|
+
from the first segment's start to the last's end."""
|
|
185
|
+
|
|
186
|
+
total: float
|
|
187
|
+
|
|
188
|
+
def __new__(cls, regions: Regions, widths: str, gap: float, width: float) -> "Segments":
|
|
189
|
+
spans = [float(r.span) for r in regions]
|
|
190
|
+
drawn = spans if widths == "span" else [sum(spans) / len(spans)] * len(spans)
|
|
191
|
+
n = len(regions)
|
|
192
|
+
room = max(width - gap * (n - 1), 1.0) # px of the segments
|
|
193
|
+
per_px = sum(drawn) / room # drawn units per px
|
|
194
|
+
gap_u = gap * per_px
|
|
195
|
+
out, u = [], 0.0
|
|
196
|
+
for i, (r, d) in enumerate(zip(regions, drawn)):
|
|
197
|
+
out.append(Segment(r, u, d / r.span, d / per_px, i))
|
|
198
|
+
u += d + gap_u
|
|
199
|
+
self = super().__new__(cls, out)
|
|
200
|
+
self.total = u - gap_u
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def at(self, chr: str, pos: float) -> Segment | None:
|
|
204
|
+
"""The segment holding ``chr:pos`` (the first, where regions overlap)."""
|
|
205
|
+
return next((s for s in self if s.holds(chr, pos)), None)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def segments_of(plot: Any, width: float | None = None) -> Segments | None:
|
|
209
|
+
"""The segments of ``plot``'s x axis for axes ``width`` px wide (default: the width its
|
|
210
|
+
genome data are binned for), or ``None``: the axis has one region or none."""
|
|
211
|
+
from .series import axis_region
|
|
212
|
+
axis = plot.x_axis
|
|
213
|
+
r = axis_region(axis)
|
|
214
|
+
if not isinstance(r, Regions):
|
|
215
|
+
return None
|
|
216
|
+
if width is None:
|
|
217
|
+
from .series import axes_width
|
|
218
|
+
width = axes_width(plot)
|
|
219
|
+
return Segments(r, axis.resolve("region_widths"), float(axis.resolve("region_gap")),
|
|
220
|
+
width)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _segment_state(state: Any, seg: Segment) -> Any:
|
|
224
|
+
"""An axis state over one segment's region, in bp (its ticks and label)."""
|
|
225
|
+
st = copy.copy(state)
|
|
226
|
+
st.segments = None
|
|
227
|
+
st.shared = None
|
|
228
|
+
st.call_ticks = None
|
|
229
|
+
st.region, st.origin = seg.region, 0.0
|
|
230
|
+
st.lo = st.flo = float(seg.region.start)
|
|
231
|
+
st.hi = st.fhi = float(seg.region.end)
|
|
232
|
+
return st
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def segment_ticks(state: Any, fonts: Any, length: float, horizontal: bool) -> None:
|
|
236
|
+
"""The ticks of an axis over several regions ``length`` px long: each segment's, as an
|
|
237
|
+
axis over its region as wide as the segment, at their drawn positions."""
|
|
238
|
+
from ..text import lines_box
|
|
239
|
+
from ..ticks import Tick
|
|
240
|
+
segs = state.segments
|
|
241
|
+
per_px = length / (segs.total or 1.0)
|
|
242
|
+
font = state.tick_font()
|
|
243
|
+
gap = state.axis.resolve("tick_label_gap")
|
|
244
|
+
ticks: list[Any] = []
|
|
245
|
+
rotation = 0.0
|
|
246
|
+
for seg in segs:
|
|
247
|
+
st = _segment_state(state, seg)
|
|
248
|
+
st._compute_ticks(fonts, (seg.u1 - seg.u0) * per_px, horizontal)
|
|
249
|
+
mine = [Tick(seg.u(t.value), t.label, t.major) for t in st.ticks]
|
|
250
|
+
if horizontal and ticks: # labels meeting across the gap: the earlier goes
|
|
251
|
+
last = max((i for i, t in enumerate(ticks) if t.label is not None), default=None,
|
|
252
|
+
key=lambda i: ticks[i].value)
|
|
253
|
+
first = min((t for t in mine if t.label is not None), default=None,
|
|
254
|
+
key=lambda t: t.value)
|
|
255
|
+
if last is not None and first is not None:
|
|
256
|
+
t = ticks[last]
|
|
257
|
+
a = (t.value * per_px + lines_box(fonts, t.label, font)[0] / 2)
|
|
258
|
+
b = (first.value * per_px - lines_box(fonts, first.label, font)[0] / 2)
|
|
259
|
+
if a + gap > b:
|
|
260
|
+
ticks[last] = Tick(t.value, None, t.major)
|
|
261
|
+
ticks += mine
|
|
262
|
+
rotation = st.rotation
|
|
263
|
+
state.ticks, state.length = ticks, length
|
|
264
|
+
state.offset_label, state.rotation = None, rotation
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def segment_labels(state: Any) -> list[tuple[Any, Segment]]:
|
|
268
|
+
"""The label of each segment of an axis over several regions (as :func:`axis_label`
|
|
269
|
+
over its region; ``None`` left out) and the segment."""
|
|
270
|
+
from ..states import label_content
|
|
271
|
+
out = []
|
|
272
|
+
for seg in state.segments:
|
|
273
|
+
content = label_content(_segment_state(state, seg))
|
|
274
|
+
if content is not None:
|
|
275
|
+
out.append((content, seg))
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Scale bar
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def check_scale_bar(v: Any) -> Any:
|
|
284
|
+
"""An axis ``scale_bar``: ``False``, ``True`` (an automatic length) or a length in bp
|
|
285
|
+
(``"10kb"``, ``5000``)."""
|
|
286
|
+
if v is None or v is False:
|
|
287
|
+
return False
|
|
288
|
+
if v is True:
|
|
289
|
+
return True
|
|
290
|
+
return parse_bp(v, "scale bar length", positive=True)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def scale_bar_on(state: Any) -> bool:
|
|
294
|
+
"""Whether an axis state draws a scale bar: an x genome axis with ``scale_bar``."""
|
|
295
|
+
return bool(state.genome and state.axis.role == "x"
|
|
296
|
+
and state.axis.resolve("scale_bar") is not False)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def bar_length(span: float) -> int:
|
|
300
|
+
"""The automatic scale bar length over ``span`` bp: the largest 1, 2 or 5 × 10ᵏ bp at
|
|
301
|
+
most a fifth of it (at least 1 bp)."""
|
|
302
|
+
best = 1
|
|
303
|
+
k = 1
|
|
304
|
+
while k <= span / 5:
|
|
305
|
+
for m in (1, 2, 5):
|
|
306
|
+
if m * k <= span / 5:
|
|
307
|
+
best = m * k
|
|
308
|
+
k *= 10
|
|
309
|
+
return best
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def bar_text(n: int) -> str:
|
|
313
|
+
"""A scale bar length written in the largest unit it is at least one of
|
|
314
|
+
(``200 kb``, ``1.5 Mb``, ``50 bp``)."""
|
|
315
|
+
unit = max((u for u, f in UNITS.items() if n >= f), key=lambda u: UNITS[u], default="bp")
|
|
316
|
+
v = n / UNITS[unit]
|
|
317
|
+
text = f"{v:.3f}".rstrip("0").rstrip(".")
|
|
318
|
+
return f"{text} {unit}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def scale_bars(state: Any, mapper: Any, room: Any = None) -> list[tuple[float, float, str]]:
|
|
322
|
+
"""The scale bars of an axis state drawing them (:func:`scale_bar_on`): ``(left,
|
|
323
|
+
right, text)`` in page units through ``mapper``, one per region, each at its right
|
|
324
|
+
end; a bar longer than its region spans it. ``room(text)``: the px its text takes
|
|
325
|
+
left of the bar; an automatic length is shortened (2, 1, 0.5 × …) until bar and
|
|
326
|
+
text fit in the region."""
|
|
327
|
+
given = state.axis.resolve("scale_bar")
|
|
328
|
+
if state.segments is not None:
|
|
329
|
+
parts = [(seg.u0, seg.u1, float(seg.region.span)) for seg in state.segments]
|
|
330
|
+
else:
|
|
331
|
+
parts = [(state.lo, state.hi, abs(state.hi - state.lo))]
|
|
332
|
+
out = []
|
|
333
|
+
for u0, u1, span in parts:
|
|
334
|
+
a, b = sorted((mapper(u0), mapper(u1)))
|
|
335
|
+
if not span > 0 or not b > a:
|
|
336
|
+
continue
|
|
337
|
+
n = bar_length(span) if given is True else int(given)
|
|
338
|
+
while given is True and room is not None and n > 1 \
|
|
339
|
+
and b - (b - a) * n / span - room(bar_text(n)) < a:
|
|
340
|
+
n = _shorter(n)
|
|
341
|
+
out.append((max(a, b - (b - a) * n / span), b, bar_text(n)))
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _shorter(n: int) -> int:
|
|
346
|
+
"""The 1, 2 or 5 × 10ᵏ length below ``n`` (one of them)."""
|
|
347
|
+
k = 10 ** (len(str(n)) - 1)
|
|
348
|
+
m = n // k
|
|
349
|
+
return 2 * k if m == 5 else k if m == 2 else max(1, k // 2)
|