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
plot_io/rows.py ADDED
@@ -0,0 +1,491 @@
1
+ """Rows of a figure: row labels in a band left of the plots, groups of rows, and lines and
2
+ bands drawn through several rows.
3
+
4
+ * A plot's y axis ``label`` with ``label_position`` ``band`` or ``left`` sits in a band left
5
+ of the y axis: the band is as wide as the widest label of the plots starting in the same
6
+ grid column, and the labels line up at the widest y axis margin of that column
7
+ (``inside``: top left inside the axes). On a plot whose class sets ``BAND_FITS`` (genome
8
+ tracks), a ``band`` label longer than the axes are high is drawn as ``left``.
9
+ * ``figure.group(first, last)`` (:class:`FigureGroup`): consecutive plots with a panel
10
+ ``letter`` in a band left of the row labels, a ``label`` as a left figure title over them
11
+ (:meth:`FigureGroup.title`, in the lanes left of their column); the group's last plot
12
+ shows the tick labels of a shared x axis.
13
+ * ``figure.vline(x, rows=…)`` (:class:`FigureVLine`) and ``figure.vspan(x0, x1, rows=…)``
14
+ (:class:`FigureVSpan`): from the top of the first plot's axes to the bottom of the last
15
+ one's, the gaps included, at x positions of the plots' x axis (drawn units); bands under
16
+ the plots, lines over them.
17
+ """
18
+
19
+ from typing import Any
20
+
21
+ from ._methods import FigureGroupMixin, FigureVLineMixin, FigureVSpanMixin
22
+ from . import groups
23
+ from .color.values import color
24
+ from .settings import Setting, Settings, family, number, table, text
25
+ from .text import Font, font_of, line_pitch, measure, plain, rich, run
26
+ from .times import axis_value
27
+ from .titles import CREATED
28
+ from .validators import length
29
+
30
+ __all__ = ["FigureVLine", "FigureVSpan", "FigureGroup", "lines", "label_text", "label_extent", "bands",
31
+ "with_bands", "place_label", "occupy_label", "place_figure"]
32
+
33
+
34
+ def lines(content: Any, font: Font | None = None) -> list[Any]:
35
+ """Content split at ``\\n``: a list of lines (strings, or lists of runs); with
36
+ ``font``, its markup read first (see :func:`~plot_io.text.rich`), so tags may span
37
+ lines."""
38
+ if content is None:
39
+ return []
40
+ if font is not None:
41
+ content = rich(content, font)
42
+ if isinstance(content, str):
43
+ return content.split("\n")
44
+ out: list[list[Any]] = [[]]
45
+ for item in content:
46
+ if isinstance(item, str):
47
+ parts = item.split("\n")
48
+ elif "\n" in item.text:
49
+ parts = [item.with_text(p) for p in item.text.split("\n")]
50
+ else:
51
+ parts = [item]
52
+ out[-1].append(parts[0])
53
+ for p in parts[1:]:
54
+ out.append([p])
55
+ return [ln if len(ln) != 1 or not isinstance(ln[0], str) else ln[0] for ln in out]
56
+
57
+
58
+ def _plot_of(v: Any) -> Any:
59
+ from .plot import Plot
60
+ p = getattr(v, "plot", v)
61
+ if not isinstance(p, Plot):
62
+ raise ValueError(f"expected a plot or one of its series, got {v!r}")
63
+ return p
64
+
65
+
66
+ def plots_of(rows: Any) -> list[Any] | None:
67
+ """The plots a ``rows=`` value names (plots or series, one or a list); ``None``: all."""
68
+ if rows is None:
69
+ return None
70
+ items = rows if isinstance(rows, (list, tuple)) else [rows]
71
+ return [_plot_of(v) for v in items]
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Row labels
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def _axis(plot: Any) -> Any:
79
+ return plot.y_axis
80
+
81
+
82
+ def label_text(plot: Any) -> Any:
83
+ """The row label of a plot: its y axis' ``label`` when its ``label_position`` puts it
84
+ in the row band or inside the axes (``band``, ``left``, ``inside``), else ``None``."""
85
+ a = _axis(plot)
86
+ if a.resolve("label_position") == "axis":
87
+ return None
88
+ content = a.resolve("label")
89
+ return None if content == "" else content
90
+
91
+
92
+ def label_font(plot: Any) -> Font:
93
+ a = _axis(plot)
94
+ return font_of(a, a.resolve("label_font_size"), a.resolve("label_weight"),
95
+ a.resolve("label_style"), a.resolve("label_color"))
96
+
97
+
98
+ def _label_position(plot: Any, fonts: Any, content: Any) -> str:
99
+ """The label's ``label_position``; ``left`` for a ``band`` label longer than the axes
100
+ are high on a plot whose class sets ``BAND_FITS``."""
101
+ pos = _axis(plot).resolve("label_position")
102
+ if pos != "band" or content is None or not getattr(plot, "BAND_FITS", False):
103
+ return pos
104
+ from .spec import current
105
+ r = current()
106
+ h = None if r is None else r.boxes.get(id(plot), {}).get("axes_height")
107
+ if h is None:
108
+ return pos
109
+ font = label_font(plot)
110
+ if max(measure(fonts, ln, font)[0] for ln in lines(content, font)) > h:
111
+ return "left"
112
+ return pos
113
+
114
+
115
+ def label_extent(plot: Any, fonts: Any) -> float:
116
+ """Width the plot's row label takes left of its y axis (with its pad); 0 inside."""
117
+ content = label_text(plot)
118
+ pos = _label_position(plot, fonts, content)
119
+ if content is None or pos == "inside":
120
+ return 0.0
121
+ font = label_font(plot)
122
+ ls = lines(content, font)
123
+ if pos == "left":
124
+ w = max(measure(fonts, ln, font)[0] for ln in ls)
125
+ else:
126
+ w = len(ls) * line_pitch(fonts, font)
127
+ return w + float(_axis(plot).resolve("label_pad"))
128
+
129
+
130
+ def with_bands(b: Any, extra: tuple[Any, ...]) -> Any:
131
+ """Bands ``b`` with the row label and group bands ``extra`` outermost on the left (the
132
+ left margin reaches past them: from the first one's offset, when it takes room)."""
133
+ import dataclasses
134
+ left, right, top, bottom = b.margins
135
+ if extra and extra[0].size:
136
+ left = max(left, extra[0].offset)
137
+ for k in extra:
138
+ left += k.size
139
+ return dataclasses.replace(b, left=b.left + extra, margins=(left, right, top, bottom))
140
+
141
+
142
+ def bands(fig: Any, areas: list[Any], fonts: Any) -> None:
143
+ """Add the row label band and the group band of their grid column to the bands of the
144
+ plots of ``areas``, outermost on the left: the labels line up at the widest y axis
145
+ margin of the column (the bands' offset), the band as wide as the column's widest
146
+ label, the group band as its widest group."""
147
+ from .geometry import Band
148
+ plots = [a for a in areas if not a.is_bar and a.is_plot]
149
+ columns: dict[int, list[Any]] = {}
150
+ for a in plots:
151
+ columns.setdefault(a.c0, []).append(a)
152
+ for col in columns.values():
153
+ base = max(a.margins[0] for a in col)
154
+ labels = max(label_extent(a.obj, fonts) for a in col)
155
+ members = {id(a.obj) for a in col}
156
+ width = max((g.extent(fonts) for g in fig.groups
157
+ if any(id(p) in members for p in g.members())), default=0.0)
158
+ extra = (Band("row_label", base, labels), Band("group", base + labels, width))
159
+ for a in col:
160
+ a.bands = with_bands(a.bands, extra)
161
+ a.state.bands = a.bands
162
+ a.margins = a.bands.margins
163
+
164
+
165
+ def _row_band(b: Any, kind: str) -> Any:
166
+ return next((k for k in b.left if k.kind == kind), None)
167
+
168
+
169
+ def place_label(state: Any, fonts: Any, rect: tuple[float, float, float, float],
170
+ b: Any, reach: float) -> tuple[Any, ...]:
171
+ """The runs of the plot's row label, lined up at its band's offset, and never within
172
+ ``reach`` (px left of the axes that the plot's own bands take there)."""
173
+ plot = state.plot
174
+ content = label_text(plot)
175
+ if content is None:
176
+ return ()
177
+ x0, y0, x1, y1 = rect
178
+ font = label_font(plot)
179
+ lh = line_pitch(fonts, font)
180
+ pad = float(_axis(plot).resolve("label_pad"))
181
+ band = _row_band(b, "row_label")
182
+ base = band.offset if band is not None else reach
183
+ if reach > base + 0.01: # bands re-measured past the column's
184
+ base = reach
185
+ right = x0 - base - pad
186
+ pos = _label_position(plot, fonts, content)
187
+ ls = lines(content, font)
188
+ out = []
189
+ if pos == "inside":
190
+ y = y0 + pad * 0.5
191
+ for ln in ls:
192
+ out.append(run(fonts, ln, x0 + pad, y, font, "left", "top"))
193
+ y += lh
194
+ return tuple(out)
195
+ lo, hi = getattr(getattr(state, "y", None), "span", (0.0, 0.0))
196
+ mid = (y0 + hi + y1 - lo) / 2 # the value lane's middle (genome lanes)
197
+ if pos == "left":
198
+ y = mid - lh * (len(ls) - 1) / 2
199
+ for ln in ls:
200
+ out.append(run(fonts, ln, right, y, font, "right", "center"))
201
+ y += lh
202
+ return tuple(out)
203
+ x = right
204
+ for ln in reversed(ls):
205
+ out.append(run(fonts, ln, x, mid, font, "center", "bottom", 90.0))
206
+ x -= lh
207
+ return tuple(out)
208
+
209
+
210
+ def occupy_label(plot: Any, ctx: Any, occ: Any) -> None:
211
+ """Marks a row label inside the axes for legend placement (its width estimated)."""
212
+ content = label_text(plot)
213
+ if content is None or _axis(plot).resolve("label_position") != "inside":
214
+ return
215
+ size = float(_axis(plot).resolve("label_font_size"))
216
+ ls = lines(content, label_font(plot))
217
+ pad = float(_axis(plot).resolve("label_pad"))
218
+ x0, y0 = ctx.rect[0] + pad, ctx.rect[1]
219
+ w = max(0.6 * size * len(plain(ln)) for ln in ls)
220
+ lh = _axis(plot).resolve("line_height")
221
+ lh = 1.2 if lh == "normal" else lh
222
+ occ.rects([x0], [y0], [x0 + w], [y0 + pad * 0.5 + lh * size * len(ls)], solid=True)
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Figure decorations
227
+ # ---------------------------------------------------------------------------
228
+
229
+ class FigureVLine(FigureVLineMixin, Settings):
230
+ """A vertical line through plots of a figure (``figure.vline``).
231
+
232
+ It runs from the top of the first of the plots ``rows`` to the bottom of the last, gaps
233
+ included, in each grid column; ``x`` is a value of the first plot's x axis in its drawn
234
+ units (a number, a date, ISO 8601 text, a category). The plots of a column it crosses
235
+ must share x (``ValueError`` otherwise). Lines are drawn over the plots, bands
236
+ (:class:`FigureVSpan`) under them.
237
+ """
238
+
239
+ SETTINGS = table(
240
+ **groups.Stroke.embed(only=("color", "line_width", "line_style"),
241
+ inherited=("line_width",)),
242
+ opacity=Setting(number(0, 1), doc="Opacity."),
243
+ )
244
+
245
+ def __init__(self, figure: Any, x: float, rows: Any = None, **settings: Any) -> None:
246
+ self._figure = figure
247
+ self.x = _position(x, "vline")
248
+ self.rows = plots_of(rows)
249
+ super().__init__(**settings)
250
+
251
+ def _parent(self) -> Settings:
252
+ return self._figure
253
+
254
+
255
+ def _position(v: Any, what: str) -> Any:
256
+ """A position along x: a number or a date (as a number), or text (a category or ISO
257
+ 8601 text, read by the axis)."""
258
+ if isinstance(v, str):
259
+ return v
260
+ try:
261
+ return axis_value(v)
262
+ except ValueError:
263
+ raise ValueError(f"fig.{what}: expected a number, a date or text, got {v!r}") from None
264
+
265
+
266
+ def _region_like(v: Any) -> bool:
267
+ if isinstance(v, str):
268
+ return True
269
+ if isinstance(v, (tuple, list)):
270
+ return len(v) == 3
271
+ return hasattr(v, "keys") or hasattr(v, "chr")
272
+
273
+
274
+ class FigureVSpan(FigureVSpanMixin, Settings):
275
+ """A vertical band through plots of a figure (``figure.vspan``): from ``x0`` to ``x1``,
276
+ or over a genome region (``x0`` a region, ``x1`` unset: its positions less the x
277
+ axis' origin)."""
278
+
279
+ SETTINGS = table(
280
+ color=Setting(color, doc="Fill color."),
281
+ opacity=Setting(number(0, 1), doc="Opacity."),
282
+ )
283
+
284
+ def __init__(self, figure: Any, x0: Any, x1: Any = None, rows: Any = None,
285
+ **settings: Any) -> None:
286
+ self._figure = figure
287
+ self.region: Any = None
288
+ if x1 is None:
289
+ if not _region_like(x0):
290
+ raise ValueError("vspan takes x0 and x1, or a region")
291
+ from .genome.regions import parse_region
292
+ self.region = parse_region(x0)
293
+ self.x0, self.x1 = float(self.region.start), float(self.region.end)
294
+ else:
295
+ self.x0, self.x1 = _position(x0, "vspan"), _position(x1, "vspan")
296
+ self.rows = plots_of(rows)
297
+ super().__init__(**settings)
298
+
299
+ def _parent(self) -> Settings:
300
+ return self._figure
301
+
302
+ def span(self, state: Any) -> tuple[float, float]:
303
+ """``(x0, x1)`` in the drawn units of an x axis state (over several regions: in the
304
+ first that the band's region overlaps; ``(-1, -1)`` in none)."""
305
+ if state.segments is not None:
306
+ if self.region is None:
307
+ raise ValueError("fig.vspan(x0, x1) on an x axis over several regions: give "
308
+ "a region (fig.vspan(\"chr1:1,000-2,000\"))")
309
+ r = self.region
310
+ for seg in state.segments:
311
+ g = seg.region
312
+ if g.chr == r.chr and r.start < g.end and r.end > g.start:
313
+ return seg.u(max(r.start, g.start)), seg.u(min(r.end, g.end))
314
+ return -1.0, -1.0
315
+ if self.region is None:
316
+ return state.value(self.x0), state.value(self.x1)
317
+ o = state.origin if state.genome else 0.0
318
+ return self.x0 - o, self.x1 - o
319
+
320
+
321
+ class FigureGroup(FigureGroupMixin, Settings):
322
+ """Consecutive plots with a common label (a left figure title over them) and letter
323
+ (left of their row labels), and the tick labels of a shared x axis under the last one
324
+ (``figure.group``).
325
+
326
+ Row labels: a y axis ``label`` with ``label_position`` ``"band"`` (rotated) or ``"left"``
327
+ (horizontal) sits in a band left of the plot, as wide as the widest label of the plots
328
+ starting in the same grid column, lined up at that column's widest y axis margin;
329
+ ``"inside"`` puts it inside the axes at the top left. The group's ``label`` is a left
330
+ figure title over its plots (normal weight, the group's font settings, ``pad`` from the
331
+ plots), made at each layout and not in ``figure.titles``; the plots must fill the grid
332
+ rectangle they span. The bold ``letter`` sits left of the row labels, level with the
333
+ top of the first plot's axes. With a shared x axis and ``label_outer``, the last plot
334
+ of the group shows tick labels too. ``plot.save()`` leaves the label out.
335
+ """
336
+
337
+ SETTINGS = table(
338
+ label=Setting(text, doc="Group label: a left figure title over the group's plots, "
339
+ "reading upward, centered on their axes."),
340
+ letter=Setting(text, doc="Panel letter at the group's top left, left of the row "
341
+ "labels."),
342
+ font_size=Setting(length(positive=True, font=True, percent="em"), doc="Font size "
343
+ "of the label."),
344
+ **groups.TextStyle.embed("letter_",
345
+ only=("font_size", "font_weight"),
346
+ docs={"font_size": "Font size of the letter.",
347
+ "font_weight": "Weight of the letter."}),
348
+ pad=Setting(length(lo=0, percent="em"), doc="Gap right of the letter, and the label's "
349
+ "`pad` (its gap to the plots)."),
350
+ font_family=Setting(family, inherited=True, doc="Font family."),
351
+ text_color=Setting(color, inherited=True, doc="Text color."),
352
+ **groups.Font.embed(only=("markup",)),
353
+ )
354
+
355
+ def __init__(self, figure: Any, first: Any, last: Any, **settings: Any) -> None:
356
+ self._figure = figure
357
+ self.first, self.last = first, last
358
+ self._made = next(CREATED)
359
+ super().__init__(**settings)
360
+
361
+ def _parent(self) -> Settings:
362
+ return self._figure
363
+
364
+ def members(self) -> list[Any]:
365
+ plots = self._figure.plots
366
+ i, j = plots.index(self.first), plots.index(self.last)
367
+ return plots[min(i, j):max(i, j) + 1]
368
+
369
+ def end(self) -> Any:
370
+ """The plot under which the group's x tick labels are drawn."""
371
+ return self.members()[-1]
372
+
373
+ def _font(self, size: str, weight: Any = "normal") -> Font:
374
+ f = font_of(self._figure, self.resolve(size), weight, color=self.resolve("text_color"),
375
+ markup=self.resolve("markup"))
376
+ f.family = self.resolve("font_family")
377
+ return f
378
+
379
+ def title(self) -> Any:
380
+ """The group's label as a figure title (``None`` without a label): on the left of
381
+ its plots, rotated 90 (reading upward), centered on their axes, in the label's font
382
+ (``font_size``, ``font_family``, ``text_color``, ``markup``, normal weight), ``pad``
383
+ from the plots; made for each layout, in the group's creation order among the
384
+ figure's titles."""
385
+ label = self.resolve("label")
386
+ if label is None:
387
+ return None
388
+ from .figure import FigureTitle
389
+ t = FigureTitle(self._figure, self.members(), text=label, side="left",
390
+ align="center", over="axes", rotation="auto", rule=False,
391
+ font_size=self.resolve("font_size"), font_weight="normal",
392
+ font_style="normal", color=self.resolve("text_color"),
393
+ font_family=self.resolve("font_family"),
394
+ markup=self.resolve("markup"), pad=self.resolve("pad"))
395
+ t._made = self._made
396
+ return t
397
+
398
+ def extent(self, fonts: Any) -> float:
399
+ """Width of the group band: the letter and ``pad`` (0 without a letter)."""
400
+ letter = self.resolve("letter")
401
+ if letter is None:
402
+ return 0.0
403
+ w = measure(fonts, letter, self._font("letter_font_size",
404
+ self.resolve("letter_weight")))[0]
405
+ return w + self.resolve("pad") if w else 0.0
406
+
407
+
408
+ def _columns(fig: Any, areas: Any, rows: Any) -> list[list[Any]]:
409
+ """The placed areas of the plots ``rows`` (``None``: all), by grid column."""
410
+ from .plot import Plot
411
+ chosen = fig.plots if rows is None else rows
412
+ ids = {id(p) for p in chosen}
413
+ out: dict[tuple[int, int], list[Any]] = {}
414
+ for a in areas:
415
+ if isinstance(a.obj, Plot) and id(a.obj) in ids:
416
+ out.setdefault((a.c0, a.c1), []).append(a)
417
+ return list(out.values())
418
+
419
+
420
+ def _one_x(col: list[Any], what: str) -> None:
421
+ """Refuse a column whose plots do not share x: one page x means nothing across them."""
422
+ if len({id(b.state.x.shared or b.state.x) for b in col}) > 1:
423
+ raise ValueError(f"fig.{what}: the plots of a grid column it crosses do not share x "
424
+ "(share x, or pass rows=)")
425
+
426
+
427
+ def _x_page(area: Any, x: Any) -> float:
428
+ x0, _, x1, _ = area.axes
429
+ state = area.state.x
430
+ return state.mapper(x0, x1)(state.value(x))
431
+
432
+
433
+ def place_figure(fig: Any, fonts: Any, areas: Any) -> tuple[tuple[Any, ...], tuple[Any, ...],
434
+ tuple[Any, ...]]:
435
+ """The figure's bands under the plots ``(span, rect)``, its lines over them ``(line,
436
+ segment)`` (each per grid column, from the top of the first plot's axes to the bottom of
437
+ the last one's) and its groups' runs."""
438
+ spans = []
439
+ for d in fig.decorations:
440
+ if not isinstance(d, FigureVSpan):
441
+ continue
442
+ for col in _columns(fig, areas, d.rows):
443
+ _one_x(col, "vspan")
444
+ a = col[0]
445
+ s0, s1 = d.span(a.state.x)
446
+ p0, p1 = sorted((_x_page(a, s0), _x_page(a, s1)))
447
+ x0, _, x1, _ = a.axes
448
+ p0, p1 = max(p0, x0), min(p1, x1)
449
+ if p1 <= p0:
450
+ continue
451
+ top = min(b.axes[1] for b in col)
452
+ bot = max(b.axes[3] for b in col)
453
+ spans.append((d, (p0, top, p1 - p0, bot - top)))
454
+ out = []
455
+ for d in fig.decorations:
456
+ if not isinstance(d, FigureVLine):
457
+ continue
458
+ for col in _columns(fig, areas, d.rows):
459
+ _one_x(col, "vline")
460
+ a = col[0]
461
+ if a.state.x.segments is not None:
462
+ raise ValueError("fig.vline on an x axis over several regions: mark a "
463
+ "position with fig.vspan(region)")
464
+ p = _x_page(a, d.x)
465
+ if not a.axes[0] - 0.01 <= p <= a.axes[2] + 0.01:
466
+ continue
467
+ top = min(b.axes[1] for b in col)
468
+ bot = max(b.axes[3] for b in col)
469
+ out.append((d, (p, top, p, bot)))
470
+ return tuple(spans), tuple(out), _place_groups(fig, fonts, areas)
471
+
472
+
473
+ def _place_groups(fig: Any, fonts: Any, areas: Any) -> tuple[Any, ...]:
474
+ """The groups' letters: each at the left of its group band, level with the top of its
475
+ first plot's axes."""
476
+ runs: list[Any] = []
477
+ for g in fig.groups:
478
+ letter = g.resolve("letter")
479
+ if letter is None:
480
+ continue
481
+ members = {id(p) for p in g.members()}
482
+ col = [a for a in areas if id(a.obj) in members]
483
+ if not col:
484
+ continue
485
+ b = col[0].bands
486
+ label_band, group_band = _row_band(b, "row_label"), _row_band(b, "group")
487
+ col_right = col[0].axes[0] - label_band.offset - label_band.size
488
+ top = min(a.axes[1] for a in col)
489
+ font = g._font("letter_font_size", g.resolve("letter_weight"))
490
+ runs.append(run(fonts, letter, col_right - group_band.size, top, font, "left", "top"))
491
+ return tuple(runs)
plot_io/scales.py ADDED
@@ -0,0 +1,103 @@
1
+ """Scales: a monotone function from data to a position line.
2
+
3
+ A numeric scale maps a value ``v`` to ``f(v)``; an axis maps ``f(lo)…f(hi)`` linearly onto
4
+ its length. ``f`` is one of the functions :func:`plot_io.kernels.scaled` applies in C, so
5
+ bulk data never goes through a Python loop.
6
+ """
7
+
8
+ import math
9
+ from collections.abc import Callable
10
+
11
+ __all__ = ["Scale", "SCALES", "get_scale", "NUMERIC_SCALES", "AXIS_SCALES"]
12
+
13
+
14
+ class Scale:
15
+ """``forward``/``inverse`` on single values; ``function`` is the kernels' name for it;
16
+ ``base`` the base of a logarithmic scale (``None`` on the others)."""
17
+
18
+ __slots__ = ("name", "function", "forward", "inverse", "lo", "hi", "options", "base")
19
+
20
+ def __init__(self, name: str, function: str | None, forward: Callable[[float], float],
21
+ inverse: Callable[[float], float], lo: float = -math.inf,
22
+ hi: float = math.inf, options: dict | None = None,
23
+ base: float | None = None) -> None:
24
+ self.name = name
25
+ self.function = function
26
+ self.forward = forward
27
+ self.inverse = inverse
28
+ self.base = base
29
+ self.lo = lo # open domain bounds for log, closed for sqrt (see contains)
30
+ self.hi = hi
31
+ self.options = options or {} # extra arguments of kernels.scaled (linthresh)
32
+
33
+ def contains(self, v: float) -> bool:
34
+ if not math.isfinite(v):
35
+ return False
36
+ if self.base is not None:
37
+ return v > 0
38
+ if self.name == "sqrt":
39
+ return v >= 0
40
+ if self.name == "exp":
41
+ return v < 709.78
42
+ if self.name == "logit":
43
+ return 0.0 < v < 1.0
44
+ return True
45
+
46
+ def f(self, v: float) -> float:
47
+ return self.forward(v) if self.contains(v) else math.nan
48
+
49
+ def __repr__(self) -> str:
50
+ return f"Scale({self.name})"
51
+
52
+
53
+ def _safe_exp(t: float) -> float:
54
+ return math.exp(t) if t < 709.78 else math.inf
55
+
56
+
57
+ def _exp10(t: float) -> float:
58
+ return 10.0 ** t if t < 308 else math.inf
59
+
60
+
61
+ SCALES = {
62
+ "linear": Scale("linear", None, lambda v: v, lambda t: t),
63
+ "log": Scale("log", "log10", math.log10, _exp10, base=10.0),
64
+ "ln": Scale("ln", "log", math.log, _safe_exp, base=math.e),
65
+ "log2": Scale("log2", "log2", math.log2, lambda t: 2.0 ** t if t < 1024 else math.inf,
66
+ base=2.0),
67
+ "log10": Scale("log10", "log10", math.log10, _exp10, base=10.0),
68
+ "sqrt": Scale("sqrt", "sqrt", math.sqrt, lambda t: t * t),
69
+ "exp": Scale("exp", "exp", _safe_exp, lambda t: math.log(t) if t > 0 else -math.inf),
70
+ }
71
+
72
+ def _symlog(c: float) -> Scale:
73
+ def f(v: float) -> float:
74
+ return math.copysign(math.log10(1.0 + abs(v) / c), v)
75
+
76
+ def inv(t: float) -> float:
77
+ return math.copysign(c * (10.0 ** abs(t) - 1.0), t) if abs(t) < 308 else \
78
+ math.copysign(math.inf, t)
79
+ return Scale("symlog", "symlog", f, inv, options={"linthresh": c})
80
+
81
+
82
+ def _logit_inverse(t: float) -> float:
83
+ if t > 300:
84
+ return 1.0
85
+ return 1.0 / (1.0 + 10.0 ** -t)
86
+
87
+
88
+ SCALES["symlog"] = _symlog(1.0)
89
+ SCALES["logit"] = Scale("logit", "logit", lambda p: math.log10(p / (1.0 - p)), _logit_inverse)
90
+
91
+ NUMERIC_SCALES = tuple(SCALES)
92
+ #: Scales an axis takes: the numeric ones, ``time`` (set by date data), ``category`` and
93
+ #: ``genome`` (linear in bp, set by genome series; see :mod:`plot_io.genome.scale`).
94
+ AXIS_SCALES = NUMERIC_SCALES + ("time", "category", "genome")
95
+
96
+ SCALES["time"] = Scale("time", None, lambda v: v, lambda t: t)
97
+
98
+
99
+ def get_scale(name: str, linthresh: float | None = None) -> Scale:
100
+ """The scale ``name`` (``linthresh``: the linear region of ``symlog``)."""
101
+ if name == "symlog" and linthresh is not None and linthresh != 1.0:
102
+ return _symlog(float(linthresh))
103
+ return SCALES[name]