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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-x86_64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
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]
|