plot-io 0.12.0__py3-none-win_amd64.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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. 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"
@@ -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)