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.
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-arm64.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,750 @@
1
+ """Intervals and gene models (:class:`Bed`, :class:`Genes`, :class:`Gtf`): boxes or gene
2
+ models packed on rows, with names, strands and colors from the data, each series in a
3
+ lane of its own (:mod:`plot_io.genome.lanes`)."""
4
+
5
+ import math
6
+ import warnings
7
+ from array import array
8
+ from typing import Any
9
+
10
+
11
+ from .._methods import BedMixin, GenesMixin, GtfMixin
12
+ from .. import groups
13
+ from ..color.values import color, nearest_named, rgba_of, variant_of, with_alpha
14
+ from ..labels import NAME_POSITIONS, row as name_row, spot
15
+ from ..settings import Setting, boolean, number, one_of, table
16
+ from ..transform import settings_both as coord_settings
17
+ from ..layers import add_layer
18
+ from ..layers.base import common
19
+ from ..layers.core import Call, Layer, Table
20
+ from ..layers.geoms import GEOMS
21
+ from ..layers.paint import Paint, role
22
+ from ..mapped import color_axis_of, unit_of
23
+ from ..presets._util import memo, placed_text
24
+ from ..spec import warn
25
+ from ..text import document_db, font_of, measure
26
+ from ..validators import length
27
+ from . import readers, sources
28
+ from .lanes import lane_mapper, lane_setting
29
+ from .pack import pack_rows
30
+ from .regions import Region, parse_region
31
+ from .series import GenomeSeries, region_setting
32
+
33
+ __all__ = ["Bed", "Genes", "Gtf"]
34
+
35
+
36
+ _BOX = 8 / 13.2 # height of boxes and coding exons, in rows: 8 px of 13.2
37
+
38
+
39
+ def _label_region(v: Any) -> Region | None:
40
+ return None if v is None else parse_region(v)
41
+
42
+
43
+ def _bed_color(v: Any) -> Any:
44
+ if isinstance(v, str) and v.strip().lower() in ("item_rgb", "score"):
45
+ return v.strip().lower()
46
+ return color(v)
47
+
48
+
49
+ def _recolor(v: Any) -> Any:
50
+ if isinstance(v, bool):
51
+ return v
52
+ return number(0, 1)(v)
53
+
54
+
55
+ def _lightness(c: Any) -> float:
56
+ """Oklab lightness of ``c`` composited over white (1 when there is no color)."""
57
+ rgba = rgba_of(c)
58
+ if rgba is None:
59
+ return 1.0
60
+ a = rgba[3] if len(rgba) > 3 else 1.0
61
+ lin = [(lambda v: v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4)(
62
+ a * float(v) + 1.0 - a) for v in rgba[:3]]
63
+ lms = [sum(k * v for k, v in zip(row, lin)) ** (1 / 3) for row in _LMS]
64
+ return 0.2104542553 * lms[0] + 0.7936177850 * lms[1] - 0.0040720342 * lms[2]
65
+
66
+
67
+ _LMS = ((0.4122214708, 0.5363325363, 0.0514459929), (0.2119034982, 0.6806995451, 0.1073969566),
68
+ (0.0883024619, 0.2817188376, 0.6299787005))
69
+
70
+
71
+ def _bp(v: Any) -> float | None:
72
+ """A position as a float; ``None`` when missing (``None``, ``"."``, ``""``, NaN)."""
73
+ if v is None or (isinstance(v, str) and v.strip() in ("", ".")) \
74
+ or (isinstance(v, float) and math.isnan(v)):
75
+ return None
76
+ return float(int(v))
77
+
78
+
79
+ def _rgb(v: Any, i: int) -> Any:
80
+ """A BED ``itemRgb`` (``"r,g,b"``) of interval ``i``, or ``None`` (``"0"``, missing);
81
+ components outside 0–255 raise."""
82
+ if v is None:
83
+ return None
84
+ if isinstance(v, (tuple, list)) and len(v) == 3:
85
+ parts: Any = list(v)
86
+ else:
87
+ parts = str(v).replace(" ", "").split(",")
88
+ if len(parts) != 3:
89
+ return None
90
+ try:
91
+ rgb = [int(c) for c in parts]
92
+ except ValueError:
93
+ return None
94
+ if not all(0 <= c <= 255 for c in rgb):
95
+ raise ValueError(f"itemRgb of interval {i + 1} is {v!r}: components are 0 to 255")
96
+ return tuple(c / 255 for c in rgb)
97
+
98
+
99
+
100
+ #: Strand chevrons' height, × the feature box's height: inside a box, on an intron.
101
+ _CHEVRON_BOX, _CHEVRON_INTRON = 0.45, 0.55
102
+
103
+ class Bed(BedMixin, GenomeSeries):
104
+ """Intervals over the region: boxes or gene models, on one row or packed onto
105
+ rows. ``reader`` is an interval reader (bigBed, BED, peaks), a path (opened with
106
+ gwseq_io) or plain data: a dict of
107
+ columns, a DataFrame, or rows (``start``, ``end`` and optionally ``name``, ``score``,
108
+ ``strand``, ``thickStart``, ``thickEnd``, ``itemRgb``, ``blockCount``,
109
+ ``blockSizes``, ``blockStarts``).
110
+
111
+ Paths and readers: see `readers <docs/api.md#readers>`_. Rows of other chromosomes than the region's are left
112
+ out (when a ``chr`` column is given). A score that is not a number (``"."``) is missing
113
+ (the map's ``bad`` color); an interval ending before it starts, or blocks not matching
114
+ ``blockCount``, are a ``ValueError``. ``itemRgb`` is ``reserved`` in bigBeds.
115
+
116
+ ``kind="genes"`` draws each BED12 model as a line, thin boxes for UTRs and thick boxes
117
+ for coding exons, chevrons on introns (``strand``), and its name in italics. A name goes where ``name_position`` says: ``auto`` tries
118
+ left of its feature, then right, then inside a feature wide enough; a feature crossing
119
+ the view's edge is named at that edge, over its visible part when the name leaves some
120
+ of it uncovered (on a background box over a dark feature). A name with no room is
121
+ shortened with an ellipsis; one with no room even shortened is left out, and a warning
122
+ counts them. In one row (``rows="collapse"``) longer features keep theirs first
123
+ (``names=False``: none).
124
+ ``name_region`` names only the features overlapping it (a locus within a wider view).
125
+
126
+ ``rows="expand"`` packs the features with their names; past ``max_rows`` it packs them
127
+ without names and names those that still fit; past ``max_rows`` again, the features of
128
+ later rows are left out and a "+n more" mark is drawn at the right of the last row.
129
+ ``rows="squish"`` packs them on rows half ``row_height`` high, without names, under the
130
+ same cap. Packing and drawing read one result: no row is kept for a name that is not
131
+ drawn.
132
+
133
+ Features are outlined in their color (``outline``, ``line_width``) over their ``fill``
134
+ (``False`` for outlines only) at ``fill_opacity``, the outline inside the feature's
135
+ extent: a feature narrower than two borders is one line, ``line_width`` wide.
136
+ ``recolor`` snaps the features' colors (``itemRgb`` ones included) to the named colors
137
+ (``"blue 0.4"``): ``True`` takes the closest of all shades, a level such as ``0.4`` the
138
+ closest color's shade at that level, so every feature has the same lightness.
139
+
140
+ Over a Hi-C triangle (``lane="over"``) each interval is an outlined triangle (a TAD);
141
+ over a square, an outlined square on the diagonal (see :class:`HiC`).
142
+
143
+ Example::
144
+
145
+ fig = plt.Tracks(("chr1", 0, 100_000), width=300)
146
+ fig.bed({"start": [5_000, 22_000, 40_000, 47_000, 70_000],
147
+ "end": [15_000, 30_000, 52_000, 60_000, 92_000],
148
+ "name": ["p1", "p2", "p3", "p4", "p5"]}, rows="expand", names=True)
149
+ """
150
+
151
+ SETTINGS = table(
152
+ region=region_setting(),
153
+ lane=lane_setting(),
154
+ kind=Setting(one_of("boxes", "genes"),
155
+ doc="`boxes` (intervals, 8/13.2 of a row high: 8 px in the default "
156
+ "row) or `genes` (BED12 models: exons as high, UTRs half, "
157
+ "introns)."),
158
+ rows=Setting(one_of("collapse", "expand", "squish"),
159
+ doc="`collapse`: one row; `expand`: overlapping features (and their "
160
+ "names) packed onto rows; `squish`: packed onto rows half "
161
+ "`row_height` high, without names."),
162
+ max_rows=Setting(number(lo=1, integer=True),
163
+ doc="Most rows of `expand` and `squish`: names are given up before "
164
+ "rows are added, then features past the last row are left out "
165
+ "and a \"+n more\" mark is drawn at the lane's lower right."),
166
+ min_width=Setting(length(lo=0), doc="Narrowest drawn feature (1-bp sites stay "
167
+ "visible)."),
168
+ color=Setting(_bed_color,
169
+ doc="A color, `\"item_rgb\"` (the `itemRgb` column) or `\"score\"` "
170
+ "(the `score` column through `color_map`)."),
171
+ recolor=Setting(_recolor,
172
+ doc="Replace each feature's color by the closest named color "
173
+ "(`True`: the closest shade; a level 0–1: the closest color's "
174
+ "shade at that level); `False`: as given. Not with "
175
+ "`color=\"score\"`."),
176
+ names=Setting(boolean, doc="Feature names (`name`) beside the features "
177
+ "(`name_position`), shortened or left out (with a "
178
+ "warning) where they collide; "
179
+ "in one row, longer features keep theirs first."),
180
+ name_position=Setting(one_of(*NAME_POSITIONS),
181
+ doc="Where a name goes: `left` or `right` of its feature, or "
182
+ "`inside` a feature wide enough; `auto`: the first of "
183
+ "left, right, inside with room. A feature crossing the "
184
+ "view's edge is named at that edge, over its visible "
185
+ "part when the name leaves some of it uncovered. A "
186
+ "name with no room is shortened with an ellipsis."),
187
+ name_region=Setting(_label_region,
188
+ doc="Only features overlapping this region (as `region` takes "
189
+ "it) get names; `None`: all."),
190
+ strand=Setting(boolean, doc="Strand chevrons on boxes and introns."),
191
+ outline=Setting(boolean, doc="Outline features in their color (`line_width` wide)."),
192
+ **groups.FillStyle.embed(docs={"fill": "Fill of the features: `True` (their "
193
+ "color), a color, or `False` (outlines only).",
194
+ "fill_opacity": "Opacity of the fill, times "
195
+ "`opacity`."}),
196
+ row_height=Setting(length(positive=True, percent="em"),
197
+ doc="Height of a row (half of it with `rows=\"squish\"`): the "
198
+ "series' lane is rows × it high (scaled to fill a track "
199
+ "without a value lane)."),
200
+ **groups.TextStyle.embed("name_",
201
+ only=("font_size",),
202
+ docs={"font_size": "Font size of feature names."}),
203
+ nan_color=Setting(color, doc="Color of features without a score under "
204
+ "`color=\"score\"`; `None`: the map's `bad` "
205
+ "(transparent)."),
206
+ **common("color_map", "name", "opacity", "z", "line_width", "font_size", "font_family",
207
+ "text_color"),
208
+ **coord_settings(),
209
+ )
210
+ READS = "intervals"
211
+
212
+ def __init__(self, reader: Any, plot: Any = None, **settings: Any) -> None:
213
+ self.reader = readers.adapt(reader, self.READS, type(self).__name__, owner=self)
214
+ super().__init__(plot=plot, **settings)
215
+
216
+ # -- data (stat intervals) ---------------------------------------------------------
217
+
218
+ def entries(self) -> dict[str, list[Any]]:
219
+ """The BED columns of the entries overlapping the region (positions in bp)."""
220
+ region = self.region()
221
+ return self.cached(region, lambda: self._guard(lambda: self._entries(region)),
222
+ kind="entries")
223
+
224
+ def _entries(self, region: Region) -> dict[str, list[Any]]:
225
+ cols = self._read(region)
226
+ if self.resolve("color") == "item_rgb":
227
+ for i, v in enumerate(cols.get("itemRgb") or ()):
228
+ _rgb(v, i) # components out of 0–255 raise
229
+ chrs = cols.get("chr")
230
+ keep = [i for i, (s, e) in enumerate(zip(cols["start"], cols["end"]))
231
+ if e > region.start and s < region.end and (chrs is None or chrs[i] == region.chr)]
232
+ names = getattr(self.reader, "chr_sizes", None) or getattr(self.reader, "chr_names",
233
+ None) or ()
234
+ if not keep and region.chr not in names:
235
+ other = region.chr[3:] if region.chr.startswith("chr") else "chr" + region.chr
236
+ if other in names:
237
+ warnings.warn(f"{self._what()}: no interval on {region.chr!r}; the data name it "
238
+ f"{other!r}", stacklevel=2)
239
+ out = {k: [v[i] for i in keep] for k, v in cols.items()}
240
+ for k in ("start", "end", "thickStart", "thickEnd"):
241
+ if k in out:
242
+ out[k] = [_bp(v) for v in out[k]]
243
+ return out
244
+
245
+ def _read(self, region: Region) -> dict[str, list[Any]]:
246
+ return sources.read_entries(self.reader, region)
247
+
248
+ def _label_font(self) -> Any:
249
+ return font_of(self, self.resolve("name_font_size"),
250
+ style="italic" if self.resolve("kind") == "genes" else "normal",
251
+ color=self.resolve("text_color"))
252
+
253
+ def _label_text(self, i: int, cols: dict[str, list[Any]], keep: int | None = None) -> Any:
254
+ """The name of entry ``i`` (``None``: none), its first ``keep`` characters and an
255
+ ellipsis when ``keep`` is given."""
256
+ names = cols.get("name")
257
+ if not names or names[i] is None or str(names[i]) in ("", "."):
258
+ return None
259
+ only = self.resolve("name_region")
260
+ if only is not None:
261
+ if only.chr != self.region().chr or cols["end"][i] <= only.start \
262
+ or cols["start"][i] >= only.end:
263
+ return None
264
+ name = str(names[i])
265
+ if keep is not None:
266
+ name = name[:keep].rstrip() + "…"
267
+ return name
268
+
269
+ def placed(self) -> "Placed":
270
+ """The rows and names of the entries, for the axes width: packing and drawing
271
+ read this one result."""
272
+ return self.cached((self.region(), self.axes_px()), self._place, kind="rows")
273
+
274
+ def layout_rows(self) -> tuple[list[int], int]:
275
+ """The row of each entry (-1: left out past ``max_rows``) and the number of
276
+ rows."""
277
+ p = self.placed()
278
+ return p.rows, p.count
279
+
280
+ def _place(self) -> "Placed":
281
+ cols = self.entries()
282
+ n = len(cols["start"])
283
+ if not n:
284
+ return Placed([], 1, [], 0, 0)
285
+ region = self.region()
286
+ ppb = self.axes_px() / region.span # px per bp
287
+ lo, hi = region.start * ppb, region.end * ppb # the view, px
288
+ mw = self.resolve("min_width")
289
+ spans = []
290
+ for i in range(n):
291
+ a, b = cols["start"][i] * ppb, cols["end"][i] * ppb # px, widened as drawn
292
+ if b - a < mw:
293
+ a, b = (a + b - mw) / 2, (a + b + mw) / 2
294
+ spans.append((a, b))
295
+ mode = self.resolve("rows")
296
+ widths: list[float | None] = [None] * n
297
+ heights = [0.0] * n
298
+ gap = 0.0
299
+ font = self._label_font()
300
+ fonts = document_db()
301
+ if self.resolve("names") and mode != "squish":
302
+ gap = 0.3 * font.size
303
+ for i in range(n):
304
+ t = self._label_text(i, cols)
305
+ if t is not None:
306
+ w, a, d = measure(fonts, t, font)
307
+ widths[i], heights[i] = w, a + d
308
+ positions = NAME_POSITIONS[self.resolve("name_position")]
309
+ order = None
310
+ more = 0
311
+ if mode == "collapse":
312
+ rows, count = [0] * n, 1
313
+ order = [b - a for a, b in spans] # longer features first
314
+ else:
315
+ cap = int(self.resolve("max_rows"))
316
+ rows = []
317
+ if any(w is not None for w in widths): # features with their names
318
+ foot = [spans[i] if w is None
319
+ else spot(*spans[i], w, lo, hi, gap, positions) or spans[i]
320
+ for i, w in enumerate(widths)]
321
+ rows = pack_rows([f[0] for f in foot], [f[1] for f in foot], gap=2.0)
322
+ if max(rows) + 1 > cap:
323
+ rows = []
324
+ if not rows: # the features alone
325
+ rows = pack_rows([a for a, _ in spans], [b for _, b in spans], gap=2.0)
326
+ count = max(rows) + 1
327
+ if count > cap:
328
+ more = sum(r >= cap for r in rows)
329
+ rows = [r if r < cap else -1 for r in rows]
330
+ count = cap
331
+ names: list[Any] = [None] * n
332
+ if any(w is not None for w in widths):
333
+ keep = [i for i in range(n) if rows[i] >= 0]
334
+ taken = None
335
+ if more: # the mark's room
336
+ w = measure(document_db(), f"+{more} more", self._more_font())[0]
337
+ taken = {count - 1: (hi - w - gap, hi)} # gap: 2 × its pad
338
+ cut = _Cut(self, cols, keep, font)
339
+ got = name_row([spans[i] for i in keep], [widths[i] for i in keep],
340
+ [rows[i] for i in keep], lo, hi, gap, positions,
341
+ None if order is None else [order[i] for i in keep], taken, cut)
342
+ for j, (i, p) in enumerate(zip(keep, got)):
343
+ if p is not None:
344
+ k = cut.kept.get(j)
345
+ drawn = widths[i] if k is None else cut.width(j, k)
346
+ names[i] = (p[0] / ppb, p[1], p[2], drawn, heights[i],
347
+ None if k is None else cut.text(j, k))
348
+ unnamed = sum(1 for i in range(n)
349
+ if rows[i] >= 0 and widths[i] is not None and names[i] is None)
350
+ return Placed(rows, count, names, more, unnamed)
351
+
352
+ def lane_rows(self) -> int:
353
+ """The number of rows of the series' lane."""
354
+ return self.layout_rows()[1]
355
+
356
+ def drawn_rows(self) -> tuple[float, float]:
357
+ """The range of rows its features cover (a box's height in its first and last
358
+ row)."""
359
+ return 0.5 - _BOX / 2, self.lane_rows() - 0.5 + _BOX / 2
360
+
361
+ # -- drawing ---------------------------------------------------------------------
362
+
363
+ def _colors(self, ctx: Any, cols: dict[str, list[Any]]) -> list[Any]:
364
+ c = self.resolve("color")
365
+ n = len(cols["start"])
366
+ black = ctx.color(self.resolve("text_color"))
367
+ if c == "item_rgb":
368
+ rgb = cols.get("itemRgb") or [None] * n
369
+ return self._recolored([_rgb(v, i) or black for i, v in enumerate(rgb)])
370
+ if c == "score":
371
+ axis = color_axis_of(self)
372
+ m, unit = axis.color_map(), unit_of(axis)
373
+ nan = ctx.color(self.resolve("nan_color"))
374
+ bad = nan if nan is not None else m.bad
375
+ out = []
376
+ for v in cols.get("score") or [None] * n:
377
+ if v is None:
378
+ out.append(bad)
379
+ else:
380
+ t = unit(v)
381
+ out.append(m(min(1.0, max(0.0, t))) if math.isfinite(t) else bad)
382
+ return out
383
+ return self._recolored([ctx.color(c)] * n)
384
+
385
+ def _recolored(self, colors: list[Any]) -> list[Any]:
386
+ r = self.resolve("recolor")
387
+ if r is False:
388
+ return colors
389
+ level = None if r is True else float(r)
390
+ variant = variant_of(self)
391
+ return [nearest_named(c, level, variant) for c in colors]
392
+
393
+ def layers(self) -> Call:
394
+ from .contacts import tads, under_hic
395
+ hic = under_hic(self)
396
+ if hic is not None: # over a Hi-C triangle: TADs
397
+ return tads(self, hic)
398
+ call = self.genome_call()
399
+ cols = self.entries()
400
+ n = len(cols["start"])
401
+ key = id(self)
402
+ placed = self.placed()
403
+ rows, spots = placed.rows, placed.names
404
+ if placed.more: # left out past max_rows
405
+ keep = [i for i in range(n) if rows[i] >= 0]
406
+ cols = {k: [v[i] for i in keep] if isinstance(v, list) and len(v) == n else v
407
+ for k, v in cols.items()}
408
+ rows, spots = [rows[i] for i in keep], [spots[i] for i in keep]
409
+ n = len(keep)
410
+
411
+ def colors(ctx: Any) -> list[Any]:
412
+ opacity = self.resolve("opacity")
413
+ opacity = 1.0 if opacity is None else opacity
414
+ return memo(ctx, (key, "colors"), lambda: [with_alpha(c, opacity)
415
+ for c in self._colors(ctx, cols)])
416
+
417
+ if n:
418
+ if self.resolve("kind") == "genes":
419
+ self._genes(call, cols, rows, colors)
420
+ else:
421
+ self._boxes(call, cols, rows, colors)
422
+ if any(p is not None for p in spots):
423
+ call.layers.append(placed_text(self, key, lambda ctx: self._label_items(
424
+ ctx, cols, rows, spots, colors), lambda ctx: self._label_font(),
425
+ overlay=False, background=self._background))
426
+ if placed.unnamed:
427
+ warn(f"{self._what()}: {placed.unnamed} feature name"
428
+ f"{'s' if placed.unnamed > 1 else ''} left out (no room)",
429
+ key=(key, "unnamed"))
430
+ if placed.more:
431
+ call.layers.append(placed_text(self, (key, "more"), lambda ctx: self._more_item(
432
+ ctx, placed), lambda ctx: self._more_font(), overlay=False,
433
+ background=self._background))
434
+ for la in call.layers:
435
+ la.occupy = False
436
+ la.options["bounds"] = False
437
+ la.key = False
438
+ if self.resolve("color") == "score":
439
+ scores = [v for v in self.entries().get("score", []) if v is not None]
440
+ call.color_sources = [(color_axis_of(self), array("d", scores))]
441
+ r = self.region()
442
+ call.extra = {"x": array("d", [r.start, r.end])}
443
+ call.hide = {"y"} # rows, in the series' lane
444
+ call.lane = lane_mapper(self)
445
+ call.exact = {"x"}
446
+ return call
447
+
448
+ def _bar_source(self) -> Any:
449
+ """``color="score"`` maps the scores through the color axis, as numbers do."""
450
+ return self if self.resolve("color") == "score" else super()._bar_source()
451
+
452
+ def _rect_paint(self, ctx: Any, colors: Any) -> dict[str, Any]:
453
+ """Features filled (``fill``) and outlined (``outline``) in their colors."""
454
+ cs = colors(ctx)
455
+ fc, fa = groups.fill_parts(self)
456
+ lw = self.resolve("line_width")
457
+ fills = cs if fc is None else [ctx.color(fc)] * len(cs)
458
+ return {"fill": [with_alpha(c, fa) for c in fills] if fa > 0 else None,
459
+ "stroke": cs if self.resolve("outline") and lw else None,
460
+ "line_width": lw}
461
+
462
+ def _span(self, ctx: Any, s: float, e: float) -> tuple[float, float]:
463
+ """Page x and width of ``[s, e)``, at least ``min_width`` wide."""
464
+ a, b = ctx.xmap(s), ctx.xmap(e)
465
+ a, b = min(a, b), max(a, b)
466
+ mw = self.resolve("min_width")
467
+ if b - a < mw:
468
+ c = (a + b) / 2
469
+ a, b = c - mw / 2, c + mw / 2
470
+ return a, b - a
471
+
472
+ def _band(self, ctx: Any, row: int, frac: float) -> tuple[float, float]:
473
+ """Page y (top) and height of the middle ``frac`` of row ``row``."""
474
+ top, bot = ctx.ymap(row + 0.5 - frac / 2), ctx.ymap(row + 0.5 + frac / 2)
475
+ return min(top, bot), abs(bot - top)
476
+
477
+ def _boxes(self, call: Call, cols: dict[str, list[Any]], rows: list[int],
478
+ colors: Any) -> None:
479
+ t = Table(x0=cols["start"], x1=cols["end"], y0=[r + 0.5 - _BOX / 2 for r in rows],
480
+ y1=[r + 0.5 + _BOX / 2 for r in rows])
481
+ call.layers.append(Layer(GEOMS["rects"], t,
482
+ paint=Paint(self, "intervals.rects", colors=colors),
483
+ stat="intervals", min_width=self.resolve("min_width"),
484
+ inset=bool(self.resolve("outline"))))
485
+ if self.resolve("strand"):
486
+ def chevrons(ctx: Any) -> list[Any]:
487
+ out: list[Any] = []
488
+ strands = cols.get("strand") or []
489
+ fills = self._rect_paint(ctx, colors)["fill"]
490
+ cs = colors(ctx)
491
+ for i, s in enumerate(strands):
492
+ if s in ("+", "-"):
493
+ x, w = self._span(ctx, cols["start"][i], cols["end"][i])
494
+ y, h = self._band(ctx, rows[i], _BOX)
495
+ # white on a dark fill, else the feature's color
496
+ ink = cs[i] if fills is None or _lightness(fills[i]) > 0.8 else "white"
497
+ self._chevrons(out, x, x + w, y + h / 2, h * _CHEVRON_BOX / 2, s, ink)
498
+ return out
499
+ lines = Table()
500
+ lines.lazy["page_lines"] = chevrons
501
+ call.layers.append(Layer(GEOMS["path"], lines, paint={}, stat="intervals"))
502
+
503
+ def _chevrons(self, out: list[Any], x0: float, x1: float, y: float, size: float,
504
+ strand: str, col: Any) -> None:
505
+ """Strand marks along ``[x0, x1]`` at ``y`` (page units), added to ``out`` as one
506
+ polyline."""
507
+ step = max(3 * size, 0.9 * self.resolve("font_size"))
508
+ if x1 - x0 < 1.5 * step:
509
+ return
510
+ k = int((x1 - x0) / step)
511
+ pad = ((x1 - x0) - (k - 1) * step) / 2
512
+ d = size if strand == "+" else -size
513
+ xs, ys = array("d"), array("d")
514
+ for j in range(k):
515
+ cx = x0 + pad + j * step
516
+ xs.extend([cx - d / 2, cx + d / 2, cx - d / 2, math.nan])
517
+ ys.extend([y - size, y, y + size, math.nan])
518
+ out.append((xs, ys, col, max(0.5, self.resolve("line_width"))))
519
+
520
+ def _genes(self, call: Call, cols: dict[str, list[Any]], rows: list[int],
521
+ colors: Any) -> None:
522
+ lw = self.resolve("line_width")
523
+ strands = cols.get("strand") or [None] * len(rows)
524
+ counts = cols.get("blockCount")
525
+ sizes = cols.get("blockSizes")
526
+ starts = cols.get("blockStarts")
527
+ t0s = cols.get("thickStart")
528
+ t1s = cols.get("thickEnd")
529
+ x0, x1, y0, y1, owner = [], [], [], [], []
530
+ blocks = []
531
+ for i in range(len(rows)):
532
+ s, e = cols["start"][i], cols["end"][i]
533
+ if counts and sizes and starts and counts[i]:
534
+ bl = [(s + a, s + a + b) for a, b in zip(sources.ints(starts[i]),
535
+ sources.ints(sizes[i]))]
536
+ else:
537
+ bl = [(s, e)]
538
+ blocks.append(bl)
539
+ t0 = t0s[i] if t0s and t0s[i] is not None else s
540
+ t1 = t1s[i] if t1s and t1s[i] is not None else e
541
+ if t1 <= t0: # non-coding: the whole model is UTR-high
542
+ t0, t1 = s, s
543
+ for a, b in bl: # coding exons as tall as boxes, UTRs half
544
+ parts = [(a, min(b, t0), _BOX / 2), (max(a, t0), min(b, t1), _BOX),
545
+ (max(a, t1), b, _BOX / 2)]
546
+ if t0 == t1 == s:
547
+ parts = [(a, b, 0.75 * _BOX)]
548
+ for pa, pb, frac in parts:
549
+ if pb <= pa:
550
+ continue
551
+ x0.append(pa)
552
+ x1.append(pb)
553
+ y0.append(rows[i] + 0.5 - frac / 2)
554
+ y1.append(rows[i] + 0.5 + frac / 2)
555
+ owner.append(i)
556
+
557
+ def models(ctx: Any) -> list[Any]:
558
+ """The gene lines and their introns' strand marks (page units), per gene."""
559
+ cs = colors(ctx)
560
+ out: list[Any] = []
561
+ for i in range(len(rows)):
562
+ c = cs[i]
563
+ cy = ctx.ymap(rows[i] + 0.5)
564
+ out.append(([ctx.xmap(cols["start"][i]), ctx.xmap(cols["end"][i])], [cy, cy],
565
+ c, lw))
566
+ if self.resolve("strand") and strands[i] in ("+", "-"):
567
+ _, h = self._band(ctx, rows[i], _BOX)
568
+ bl = blocks[i]
569
+ for (_, b0), (a1, _) in zip(bl, bl[1:]):
570
+ self._chevrons(out, ctx.xmap(b0), ctx.xmap(a1), cy, h * _CHEVRON_INTRON / 2,
571
+ strands[i], c)
572
+ return out
573
+
574
+ lines = Table()
575
+ lines.lazy["page_lines"] = models
576
+ call.layers.append(Layer(GEOMS["path"], lines, paint={}, stat="intervals"))
577
+ call.layers.append(Layer(GEOMS["rects"], Table(x0=x0, x1=x1, y0=y0, y1=y1),
578
+ paint=Paint(self, "intervals.rects", colors=colors,
579
+ owner=owner), stat="intervals", min_width=self.resolve("min_width"),
580
+ inset=bool(self.resolve("outline"))))
581
+
582
+ def _label_items(self, ctx: Any, cols: dict[str, list[Any]], rows: list[int],
583
+ spots: list[Any], colors: Any) -> list[Any]:
584
+ """Feature names where :meth:`placed` put them; a name over a dark feature on a
585
+ background box."""
586
+ font = self._label_font()
587
+ fills = self._rect_paint(ctx, colors)["fill"]
588
+ pad = 0.15 * font.size
589
+ out: list[Any] = []
590
+ for i, p in enumerate(spots):
591
+ if p is None:
592
+ continue
593
+ x, ha, where, w, h, short = p
594
+ x, y = ctx.xmap(x), ctx.ymap(rows[i] + 0.5)
595
+ text_ = self._label_text(i, cols) if short is None else short
596
+ if where in ("inside", "edge") and fills is not None \
597
+ and _lightness(fills[i]) < 0.6:
598
+ x += w / 2 if ha == "left" else -w / 2 if ha == "right" else 0.0
599
+ out.append((text_, x, y, font.color, "center", "center", 0.0,
600
+ (w, h, pad)))
601
+ else:
602
+ out.append((text_, x, y, font.color, ha, "center"))
603
+ return out
604
+
605
+ def _background(self, ctx: Any) -> Any:
606
+ fig = self.plot.figure
607
+ return next(c for c in (ctx.color(self.plot.resolve("background")),
608
+ ctx.color(fig.resolve("background")), "white")
609
+ if c is not None)
610
+
611
+ def _more_font(self) -> Any:
612
+ return font_of(self, self.resolve("name_font_size"), color=self.resolve("text_color"))
613
+
614
+ def _more_item(self, ctx: Any, placed: "Placed") -> list[Any]:
615
+ """The "+n more" mark at the right of the series' last row, on a background
616
+ box."""
617
+ font = self._more_font()
618
+ text_ = f"+{placed.more} more"
619
+ w, a, d = measure(ctx.canvas, text_, font)
620
+ pad = 0.15 * font.size
621
+ return [(text_, ctx.rect[2] - w / 2 - pad, ctx.ymap(placed.count - 0.5), font.color,
622
+ "center", "center", 0.0, (w, a + d, pad))]
623
+
624
+ def row_px(self) -> float:
625
+ """The height of one of its rows (px)."""
626
+ h = float(self.resolve("row_height"))
627
+ return h / 2 if self.resolve("rows") == "squish" else h
628
+
629
+
630
+ class Placed:
631
+ """The rows of a series' entries (``rows``: -1 for an entry left out past
632
+ ``max_rows``; ``count`` rows), their names (per entry ``(x, ha, where, w, h, short)``:
633
+ the anchor in bp, its alignment, its position (:data:`plot_io.labels.Spot`), its size
634
+ in px and the shortened name drawn (``None``: the whole name); or ``None``), how many
635
+ entries were left out (``more``) and how many names (``unnamed``)."""
636
+
637
+ __slots__ = ("rows", "count", "names", "more", "unnamed")
638
+
639
+ def __init__(self, rows: list[int], count: int, names: list[Any], more: int,
640
+ unnamed: int) -> None:
641
+ self.rows, self.count, self.names, self.more = rows, count, names, more
642
+ self.unnamed = unnamed
643
+
644
+
645
+ class _Cut:
646
+ """Names shortened with an ellipsis (:func:`plot_io.labels.row`'s ``cut``) of the
647
+ entries ``keep`` of ``cols``: ``k`` characters kept, measured once."""
648
+
649
+ def __init__(self, series: Any, cols: dict[str, list[Any]], keep: list[int],
650
+ font: Any) -> None:
651
+ self.series, self.cols, self.keep, self.font = series, cols, keep, font
652
+ self.kept: dict[int, int] = {}
653
+ self._widths: dict[tuple[int, int], float] = {}
654
+
655
+ def steps(self, j: int) -> int:
656
+ names = self.cols.get("name") or ()
657
+ i = self.keep[j]
658
+ return max(0, len(str(names[i])) - 1) if i < len(names) else 0
659
+
660
+ def text(self, j: int, k: int) -> Any:
661
+ return self.series._label_text(self.keep[j], self.cols, k)
662
+
663
+ def width(self, j: int, k: int) -> float:
664
+ if (j, k) not in self._widths:
665
+ self._widths[(j, k)] = measure(document_db(), self.text(j, k), self.font)[0]
666
+ return self._widths[(j, k)]
667
+
668
+
669
+ class Genes(GenesMixin, Bed):
670
+ """Gene models (BED12): ``Bed`` with ``kind="genes"``, rows expanded and names on.
671
+
672
+ Filled solid without outlines; the settings are :class:`Bed`'s, its own defaults below.
673
+
674
+ Example::
675
+
676
+ fig = plt.Tracks(("chr1", 13_550_000, 13_980_000), width=300)
677
+ fig.genes({"start": [13_600_000], "end": [13_930_000], "name": ["Pcsk2"], "strand": ["+"],
678
+ "thickStart": [13_640_000], "thickEnd": [13_920_000], "blockCount": [5],
679
+ "blockSizes": ["3000,2000,2500,2000,9000"],
680
+ "blockStarts": ["0,90000,170000,240000,321000"]})
681
+ """
682
+
683
+
684
+ def _types(v: Any) -> Any:
685
+ if v is None or isinstance(v, str):
686
+ return v
687
+ if isinstance(v, (list, tuple)) and v and all(isinstance(t, str) for t in v):
688
+ return list(v)
689
+ raise ValueError(f"expected a type name, a list of them or None, got {v!r}")
690
+
691
+
692
+ class Gtf(GtfMixin, Genes):
693
+ """Gene models of a GTF or GFF3 reader (``read_entries(model=)``) or path: ``Genes``, one
694
+ model per gene (its transcripts' exons merged) or per transcript, or plain BED12 data
695
+ as ``Genes`` takes it.
696
+
697
+ A path is a bgzipped, indexed GTF or GFF3 (``"gencode.gtf.gz"``; ``gwseq_io.sort_gtf``
698
+ makes one from any GTF; `readers <docs/api.md#readers>`_). The transcripts overlapping the region come whole,
699
+ with their exons outside it. With ``model="gene"`` each gene is one model: its
700
+ transcripts' exons merged, coding from the first CDS start to the last CDS end (the stop
701
+ codon included), named by ``gene_name``; ``model="transcript"`` draws every transcript,
702
+ named by ``transcript_name``. An id names what has no name. ``gene_type`` and
703
+ ``transcript_type`` keep those biotypes. A :class:`Bed` or :class:`Genes` given a GTF
704
+ reader raises ``TypeError`` pointing here.
705
+
706
+ Example::
707
+
708
+ fig = plt.Tracks(("chr1", 0, 60_000), width=300)
709
+ fig.gtf({"start": [4_000, 30_000], "end": [26_000, 55_000], "name": ["Abc1", "Def2"],
710
+ "strand": ["+", "-"], "thickStart": [6_000, 32_000], "thickEnd": [24_000, 52_000],
711
+ "blockCount": [3, 2], "blockSizes": ["3000,4000,4000", "6000,5000"],
712
+ "blockStarts": ["0,8000,18000", "0,20000"]})
713
+ """
714
+
715
+ SETTINGS = table(
716
+ **Genes.SETTINGS,
717
+ model=Setting(one_of("gene", "transcript"),
718
+ doc="`gene`: one model per gene, its transcripts' exons merged and "
719
+ "coding from the first CDS start to the last CDS end, named by "
720
+ "`gene_name`; `transcript`: one per transcript, named by "
721
+ "`transcript_name` (ids where there is no name)."),
722
+ gene_type=Setting(_types, doc="Gene biotypes kept (`\"protein_coding\"`, a list); "
723
+ "`None`: all."),
724
+ transcript_type=Setting(_types, doc="Transcript biotypes kept; `None`: all."),
725
+ )
726
+ READS = "annotation"
727
+
728
+
729
+ def _read(self, region: Region) -> dict[str, list[Any]]:
730
+ if sources.kind_of(self.reader) != "annotation": # BED12 data
731
+ return sources.read_entries(self.reader, region)
732
+ return sources.read_models(self.reader, region, self.resolve("model"),
733
+ self.resolve("gene_type"), self.resolve("transcript_type"))
734
+
735
+
736
+ add_layer("bed", Bed)
737
+ add_layer("genes", Genes)
738
+ add_layer("gtf", Gtf)
739
+
740
+
741
+ @role("intervals.rects")
742
+ def _rects_paint(s: Any, ctx: Any, glyph: bool, colors: Any = None,
743
+ owner: Any = None) -> dict[str, Any]:
744
+ """Features (``owner``: exons, each in its feature's colors)."""
745
+ p = s._rect_paint(ctx, colors)
746
+ if owner is not None:
747
+ for k in ("fill", "stroke"):
748
+ if isinstance(p[k], list):
749
+ p[k] = [p[k][i] for i in owner]
750
+ return p