plot-io 0.12.0__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/figure.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
"""Figures: plots (and colorbars) arranged in a grid, with sizes and output."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Self, Unpack
|
|
7
|
+
|
|
8
|
+
from .canvas import Canvas
|
|
9
|
+
from ._methods import FigureMixin, FigureTitleMixin, MainTitleMixin
|
|
10
|
+
from . import groups
|
|
11
|
+
from .color.values import VARIANT_DOC, check_variant, color
|
|
12
|
+
|
|
13
|
+
from .settings import (DECLINE, custom_vars, BASE_THEME, Setting, Settings, Theme, boolean, dpi,
|
|
14
|
+
dpi_or_bool, either, literal, number, one_of, overriding,
|
|
15
|
+
simplify_value, table)
|
|
16
|
+
from .titles import CREATED, TitleBase
|
|
17
|
+
from .units import Dim, parse
|
|
18
|
+
from .validators import Custom, length, real, typed
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .genome.layout import Track
|
|
22
|
+
from .mapped import ColorAxis, SizeAxis
|
|
23
|
+
from .plot import Plot
|
|
24
|
+
from .rows import FigureVLine, FigureVSpan
|
|
25
|
+
from .types import (ColorAxisSettings, FigureSettings, FigureVLineSettings,
|
|
26
|
+
FigureVSpanSettings, PlotSettings, SizeAxisSettings, TrackSettings,
|
|
27
|
+
WriteOptions)
|
|
28
|
+
|
|
29
|
+
__all__ = ["Figure"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _side(what: str) -> Any:
|
|
33
|
+
"""A figure width or height: a length, or a paper size's side (``"a4"``, px)."""
|
|
34
|
+
base = length(positive=True, scaled=False)
|
|
35
|
+
|
|
36
|
+
def check(v: Any) -> Any:
|
|
37
|
+
if isinstance(v, str):
|
|
38
|
+
from vec_io.units import named_size
|
|
39
|
+
pt = named_size(v.strip(), what)
|
|
40
|
+
if pt is not None:
|
|
41
|
+
return pt / 0.75
|
|
42
|
+
return base(v)
|
|
43
|
+
return Custom(check, like=base)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _grid(v: Any) -> list[list[Any]]:
|
|
47
|
+
if isinstance(v, (str, bytes)):
|
|
48
|
+
raise ValueError(f"expected \"auto\", \"tracks\" or rows of ids, got {v!r}")
|
|
49
|
+
rows = [list(r) for r in v]
|
|
50
|
+
if not rows or not rows[0]:
|
|
51
|
+
raise ValueError("a grid needs at least one cell")
|
|
52
|
+
if any(len(r) != len(rows[0]) for r in rows):
|
|
53
|
+
raise ValueError("grid rows must have the same length")
|
|
54
|
+
cells: dict[Any, list[tuple[int, int]]] = {}
|
|
55
|
+
for i, r in enumerate(rows):
|
|
56
|
+
for j, k in enumerate(r):
|
|
57
|
+
if k is None or k == 0:
|
|
58
|
+
continue
|
|
59
|
+
cells.setdefault(k, []).append((i, j))
|
|
60
|
+
for k, cs in cells.items():
|
|
61
|
+
r0 = min(c[0] for c in cs)
|
|
62
|
+
r1 = max(c[0] for c in cs) + 1
|
|
63
|
+
c0 = min(c[1] for c in cs)
|
|
64
|
+
c1 = max(c[1] for c in cs) + 1
|
|
65
|
+
if len(cs) != (r1 - r0) * (c1 - c0) or any(rows[i][j] != k for i in range(r0, r1)
|
|
66
|
+
for j in range(c0, c1)):
|
|
67
|
+
raise ValueError(f"grid id {k!r} does not cover a rectangle of cells")
|
|
68
|
+
return rows
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _layout(v: Any) -> Any:
|
|
72
|
+
"""``"auto"``, ``"tracks"`` or rows of ids (an explicit grid)."""
|
|
73
|
+
if v in ("auto", "tracks"):
|
|
74
|
+
return v
|
|
75
|
+
return _grid(v)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_TRACK = length(positive=True, percent="room", fr=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tracks(v: Any) -> list[Any]:
|
|
82
|
+
"""Column widths or row heights: ratios (numbers or ``fr``) and fixed lengths."""
|
|
83
|
+
if isinstance(v, str) or real(v) is not None or not hasattr(v, "__iter__"):
|
|
84
|
+
raise ValueError(f"expected a list of widths, got {v!r}")
|
|
85
|
+
out = [_TRACK(x) for x in v]
|
|
86
|
+
if not out:
|
|
87
|
+
raise ValueError("expected at least one width")
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_UNSET = object()
|
|
92
|
+
_WIDTH_OF_HEIGHT = parse("calc(var(height) * var(aspect_ratio))")
|
|
93
|
+
_TRACKS_WIDTH = 480.0
|
|
94
|
+
_FR = Dim("1fr", 1.0, "fr")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _kind_settings() -> Any:
|
|
98
|
+
from .color.maps import kind_settings
|
|
99
|
+
return kind_settings()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_fonts(v: Any) -> Any:
|
|
103
|
+
"""A vec_io ``Fonts``, or a dict of its fields (kept as given)."""
|
|
104
|
+
import vec_io
|
|
105
|
+
vec_io.Fonts.of(v, "fonts")
|
|
106
|
+
return dict(v) if isinstance(v, dict) else v
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _sharing() -> Any:
|
|
110
|
+
from . import sharing
|
|
111
|
+
return sharing
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _styles() -> Any:
|
|
115
|
+
from . import styles
|
|
116
|
+
return styles
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@typed("Literal['auto', 'lossless'] | SupportsFloat")
|
|
121
|
+
def check_quality(v: Any) -> Any:
|
|
122
|
+
"""``"auto"``, ``"lossless"`` or a quality from 1 to 100."""
|
|
123
|
+
return either(literal("auto", "lossless"), number(lo=1, hi=100, integer=True))(v)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def output_quality(value: Any, fmt: str) -> int | None:
|
|
127
|
+
"""The vec_io quality of a ``quality`` value for output format ``fmt`` (``"png"``,
|
|
128
|
+
``"jpeg"``, ``"jxl"``, ``"avif"`` or ``"pdf"``): ``None`` for lossless, or for vec_io's
|
|
129
|
+
default (JPEG 90)."""
|
|
130
|
+
v = check_quality(value)
|
|
131
|
+
if v == "auto":
|
|
132
|
+
return 90 if fmt == "png" else None
|
|
133
|
+
if v == "lossless":
|
|
134
|
+
if fmt == "jpeg":
|
|
135
|
+
raise ValueError("JPEG has no lossless mode (use PNG, JPEG XL or AVIF)")
|
|
136
|
+
return None
|
|
137
|
+
return v
|
|
138
|
+
|
|
139
|
+
class FigureTitle(FigureTitleMixin, TitleBase):
|
|
140
|
+
"""A title of the figure: its main title (``figure.title``), a band above the plots,
|
|
141
|
+
or another one over a set of plots (``figure.add_title``), in the lanes between them.
|
|
142
|
+
|
|
143
|
+
The main title is the outermost lane of the top, over every plot: the figure grows by it
|
|
144
|
+
(with ``reference="figure"`` the plots shrink). ``over``: ``"axes"`` aligns it within
|
|
145
|
+
the leftmost to rightmost axes, ``"plot"`` within their boxes, ``"figure"`` within the
|
|
146
|
+
figure less ``margin``. ``rule="outside"`` draws a bar above it, ``True`` a line
|
|
147
|
+
between it and the plots. Style keys ``"MainTitle.name"`` apply to the main title alone,
|
|
148
|
+
``"FigureTitle.name"`` to every figure title.
|
|
149
|
+
|
|
150
|
+
``figure.add_title(text, plots=…, side=…)`` covers the grid rectangle of its plots' cells
|
|
151
|
+
(a plot inside it but not given is an error naming it). Lanes: a title sits in the line
|
|
152
|
+
between cells beside its plots; the titles of one side stack outward, a title whose
|
|
153
|
+
plots are a subset of another's nearer, titles over disjoint spans sharing a lane,
|
|
154
|
+
overlapping ones in creation order; ``order`` overrides (lower nearer). A lane is as
|
|
155
|
+
thick as its thickest title. Over the same plots, from the plots outward: legend,
|
|
156
|
+
colorbar, title. The gap at a line is ``spacing`` plus its lanes, along the whole line,
|
|
157
|
+
so the titles in it line up. Titles on top and bottom wrap to their span; on the left
|
|
158
|
+
and right they do not, and read upward (left) or downward (right).
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
SETTINGS = table(**{
|
|
162
|
+
**TitleBase.SETTINGS,
|
|
163
|
+
"over": Setting(one_of("axes", "plot", "figure"),
|
|
164
|
+
doc="Span it is aligned and wrapped within: from the first to the "
|
|
165
|
+
"last axes of its plots, their boxes, or the whole figure."),
|
|
166
|
+
"pad": Setting(length(lo=0), doc="Gap between the title and the plots."),
|
|
167
|
+
"order": Setting(number(), doc="Place in its side's lanes: lower is nearer the plots; "
|
|
168
|
+
"unset: a title nested in another nearer, then "
|
|
169
|
+
"creation order."),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
def __init__(self, figure: "Figure", plots: Any = None, **settings: Any) -> None:
|
|
173
|
+
self._figure = figure
|
|
174
|
+
#: the plots it covers (``None``: every plot)
|
|
175
|
+
self.plots: list[Any] | None = plots
|
|
176
|
+
self._made = next(CREATED)
|
|
177
|
+
super().__init__(**settings)
|
|
178
|
+
|
|
179
|
+
def _parent(self) -> Settings:
|
|
180
|
+
return self._figure
|
|
181
|
+
|
|
182
|
+
def __call__(self, text: Any = _UNSET, **settings: Any) -> "FigureTitle":
|
|
183
|
+
"""Set the title (``None`` removes it) and its settings."""
|
|
184
|
+
if text is not _UNSET:
|
|
185
|
+
settings["text"] = text
|
|
186
|
+
return self.set(**settings)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class MainTitle(MainTitleMixin, FigureTitle):
|
|
190
|
+
"""The figure's main title (``figure.title``): ``MainTitle.*`` style keys apply to it
|
|
191
|
+
alone, ``FigureTitle.*`` keys to every figure title."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _tracks_rule(value: Any) -> Any:
|
|
195
|
+
"""A figure rule giving ``value`` in a tracks layout."""
|
|
196
|
+
def rule(fig: "Figure") -> Any:
|
|
197
|
+
return value if fig.tracks_layout() else DECLINE
|
|
198
|
+
return rule
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class Figure(FigureMixin, Settings):
|
|
202
|
+
"""A figure: plots arranged in an explicit grid of ids (``layout``) or automatically
|
|
203
|
+
(``rows``, ``cols``, ``fill_order``), sized by ``width``, ``height`` and ``aspect_ratio`` (CSS px)
|
|
204
|
+
of the box ``reference`` names: each cell's axes, each cell's plot, or the figure.
|
|
205
|
+
|
|
206
|
+
**Arrangement.** ``layout=[[1, 1, 2], [3, 3, 2]]``: each id covers a rectangle of cells
|
|
207
|
+
(``0`` or ``None``: empty) and holds a plot or a colorbar; ``figure.plot()`` returns the
|
|
208
|
+
next id without a plot. ``layout="auto"`` places plots in creation order: ``cols=4`` row
|
|
209
|
+
by row, at most 4 per row; ``rows=4`` column by column; ``rows=2, cols=3`` a fixed grid
|
|
210
|
+
(full: ``ValueError``); neither, ⌈√n⌉ columns chosen when drawn. ``layout="tracks"``:
|
|
211
|
+
one column top to bottom, sharing x, ``spacing`` 0 and ``width`` 480 px unless given,
|
|
212
|
+
each row as high as its plot's ``height`` (a :class:`Track`: from its content; other
|
|
213
|
+
plots ``1fr``).
|
|
214
|
+
|
|
215
|
+
**Sizes.** With one of ``width`` and ``height``, ``aspect_ratio`` gives the other;
|
|
216
|
+
giving all three raises ``ValueError``. Plots with a ``data_ratio`` (and pies) take the
|
|
217
|
+
other side from it. ``reference="figure"``: the cells share what the decorations leave
|
|
218
|
+
with axes of at least 8 px (a figure too small for them grows, with one warning).
|
|
219
|
+
``widths``/``heights``: ratios, or fixed lengths the ratios share the rest around (in
|
|
220
|
+
``%``, of the plots' room: what the cells share, as in a CSS grid; more than 100 %, or
|
|
221
|
+
100 % beside ratio cells, raises); a plot spanning cells gets their sizes plus the gaps.
|
|
222
|
+
``align="axes"``: a gutter holds the widest margin before it, the widest after it and
|
|
223
|
+
``spacing``; ``align="borders"``: each plot's axes are what its own decorations leave of
|
|
224
|
+
its box. Columns (rows) of vertical (horizontal) colorbars only
|
|
225
|
+
are as wide as the bar, unless ``widths`` (``heights``) is given.
|
|
226
|
+
|
|
227
|
+
**Layout.** A plot is its axes rectangle with four stacks of bands, from the axes
|
|
228
|
+
outward: tick marks, tick labels, the axis label, twin axes, the ×10ᵏ offset, an
|
|
229
|
+
outside legend, attached colorbars, the plot's titles and ``space_above``; outermost on
|
|
230
|
+
the left, the row label band and the band of group letters. A side's margin is its
|
|
231
|
+
bands' thicknesses, at least the overhangs of the bands across it; an axis label longer
|
|
232
|
+
than the plot's box wraps at spaces. Each line between cells takes the largest margin
|
|
233
|
+
of the plots there, and its lanes: figure titles, legends, colorbars and group labels
|
|
234
|
+
(:class:`FigureTitle`). Only what is circular is repeated, in rounds: tick labels set
|
|
235
|
+
the margins, which set the axes length (``reference="plot"`` or ``"figure"``),
|
|
236
|
+
``data_ratio``, stats binned at the axes' resolution, wrapping figure titles. Rounds
|
|
237
|
+
stop when no axes size moves by more than 1/3 px, after 5 at most; ``inside="auto"``
|
|
238
|
+
legends are placed on the placed axes. The layout ends in a frozen geometry (every
|
|
239
|
+
box, tick and shaped text run in px) that drawing reads without measuring.
|
|
240
|
+
|
|
241
|
+
**One plot.** ``plot.save()`` lays out the whole figure and keeps the plot at its size
|
|
242
|
+
in it (its axes; with ``align="borders"`` its box), with its outer tick labels, row
|
|
243
|
+
label, titles, legend and attached colorbars, and the limits of its shared axes; figure
|
|
244
|
+
titles, legends and colorbars are left out.
|
|
245
|
+
|
|
246
|
+
**Fonts.** Each character takes the first family of ``font_family`` that is available
|
|
247
|
+
(from ``fonts``: its ``dirs``, then ``VEC_IO_FONT_DIRS``, then installed fonts unless
|
|
248
|
+
``system=False``; a missing family tries its metric aliases: Helvetica and Arial →
|
|
249
|
+
Liberation Sans, Arimo, …), then a generic family's list, then any font with the
|
|
250
|
+
character; a ``font_family`` none of whose families is available warns once. The
|
|
251
|
+
default list is Helvetica where installed, else the first of the others (all have
|
|
252
|
+
Helvetica's widths); with none installed, vec_io downloads Nimbus Sans. With no outline
|
|
253
|
+
font at all, output raises ``NoFontError`` (a ``ValueError``).
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
SETTINGS = table(
|
|
257
|
+
layout=Setting(_layout,
|
|
258
|
+
doc="`auto`: plots in automatic rows and columns (`rows`, `cols`, "
|
|
259
|
+
"`fill_order`); rows of ids (`[[1, 1, 2], [3, 4, 2]]`): each id "
|
|
260
|
+
"covers a rectangle of cells, `0`/`None` leaves a cell empty; "
|
|
261
|
+
"`tracks`: one column of plots top to bottom in creation order, "
|
|
262
|
+
"sharing x, each row as high as its plot's `height` (tracks: from "
|
|
263
|
+
"their content; other plots `1fr`)."),
|
|
264
|
+
rows=Setting(number(lo=1, hi=10_000, integer=True), doc="Automatic arrangement: rows."),
|
|
265
|
+
cols=Setting(number(lo=1, hi=10_000, integer=True), doc="Automatic arrangement: columns."),
|
|
266
|
+
fill_order=Setting(one_of("auto", "row", "col"),
|
|
267
|
+
doc="Fill order of the automatic arrangement: `auto` (row by row "
|
|
268
|
+
"with `cols`, column by column with `rows`), `row` or "
|
|
269
|
+
"`col`."),
|
|
270
|
+
align=Setting(one_of("axes", "borders"),
|
|
271
|
+
doc="`axes`: axis lines line up across plots; `borders`: plot boxes "
|
|
272
|
+
"line up."),
|
|
273
|
+
widths=Setting(_tracks, doc="Column widths: ratios (numbers or `fr`) sharing the "
|
|
274
|
+
"room left by fixed lengths (`\"2in\"`, `\"30%\"` of "
|
|
275
|
+
"the plots' room)."),
|
|
276
|
+
heights=Setting(_tracks, doc="Row heights, as `widths`."),
|
|
277
|
+
width=Setting(_side("width"),
|
|
278
|
+
doc="Width of the `reference` box: one cell's axes, one cell's "
|
|
279
|
+
"plot, or the figure; a paper size names its width (`\"a4\"`, "
|
|
280
|
+
"`\"letter\"`, `\"a4-landscape\"`)."),
|
|
281
|
+
height=Setting(_side("height"), doc="Height of the `reference` box (a paper size: its "
|
|
282
|
+
"height)."),
|
|
283
|
+
aspect_ratio=Setting(number(lo=0.001, hi=1000),
|
|
284
|
+
doc="Width / height of the `reference` box (16:9 → `16 / 9`), "
|
|
285
|
+
"when only one of `width` and `height` is given."),
|
|
286
|
+
reference=Setting(one_of("axes", "plot", "figure"),
|
|
287
|
+
doc="What `width` and `height` measure: `axes` (each grid cell's "
|
|
288
|
+
"axes rectangle), `plot` (each cell's whole plot box) or "
|
|
289
|
+
"`figure`."),
|
|
290
|
+
spacing=Setting(length(lo=0, percent="figure"), doc="Minimum gap between neighbouring plots."),
|
|
291
|
+
margin=Setting(length(lo=0, percent="figure"), doc="Space around the figure."),
|
|
292
|
+
background=Setting(color, doc="Figure background, default the style's (white); "
|
|
293
|
+
"`\"none\"` or `\"transparent\"`: transparent (white "
|
|
294
|
+
"in JPEG, which has no transparency). `None` unsets it."),
|
|
295
|
+
fonts=Setting(_check_fonts, expr=False,
|
|
296
|
+
doc="Where fonts come from: a vec_io `Fonts` or a dict of its fields "
|
|
297
|
+
"(`dirs`, `system`, `downloads`: `True`, `False` or `\"cache\"`, "
|
|
298
|
+
"`db`); unset: `VEC_IO_FONT_DIRS`, installed fonts and "
|
|
299
|
+
"`VEC_IO_FONT_DOWNLOADS` (downloads on by default)."),
|
|
300
|
+
default_dpi=Setting(dpi,
|
|
301
|
+
doc="Resolution of PNG, JPEG, JPEG XL and AVIF output when "
|
|
302
|
+
"`save`/`write` give no `dpi`: dots per inch, or a multiple "
|
|
303
|
+
"of 96 (`\"2x\"` is 192); `96` gives one image pixel per px, "
|
|
304
|
+
"the default `\"4x\"` four. PDF, EPS and SVG output never use "
|
|
305
|
+
"it."),
|
|
306
|
+
quality=Setting(check_quality,
|
|
307
|
+
doc="Quality (1–100), as vec_io's: of PNG output (lossy: at most 256 "
|
|
308
|
+
"indexed colors), JPEG, JPEG XL and AVIF output, and of "
|
|
309
|
+
"rasterized content in PDF and SVG output (stored as JPEG); `\"lossless\"` "
|
|
310
|
+
"(not JPEG); `\"auto\"`: 90 for PNG and JPEG, lossless for JPEG "
|
|
311
|
+
"XL, AVIF and PDF content."),
|
|
312
|
+
share_x=Setting(lambda v: _sharing().check_figure_share(v),
|
|
313
|
+
doc="Plots sharing their x axis: `False`, `True`/`\"all\"`, `\"col\"` "
|
|
314
|
+
"(same column span) or `\"row\"` (same row span)."),
|
|
315
|
+
share_y=Setting(lambda v: _sharing().check_figure_share(v),
|
|
316
|
+
doc="Plots sharing their y axis, as `share_x`."),
|
|
317
|
+
share=Setting(lambda v: _sharing().check_share(v),
|
|
318
|
+
doc="Both axes at once: as `share_x`, or `\"grid\"` (x per column, y "
|
|
319
|
+
"per row); `share_x`/`share_y` override it."),
|
|
320
|
+
share_color=Setting(lambda v: _sharing().check_figure_share(v),
|
|
321
|
+
doc="Plots whose series' own color axes are one (limits over "
|
|
322
|
+
"all their values), as `share_x`."),
|
|
323
|
+
share_size=Setting(lambda v: _sharing().check_figure_share(v),
|
|
324
|
+
doc="Plots whose series' own size axes are one (limits over all "
|
|
325
|
+
"their values, one size key), as `share_x`."),
|
|
326
|
+
share_legend=Setting(lambda v: _sharing().check_legend_share(v),
|
|
327
|
+
doc="Legends shared by plots, made by the figure: `False`, "
|
|
328
|
+
"`True`/`\"all\"` (one on the right of the figure), "
|
|
329
|
+
"`\"row\"` (one on the right of each row), `\"col\"` "
|
|
330
|
+
"(one at the bottom of each column); a plot with "
|
|
331
|
+
"`legend=False` adds no entries."),
|
|
332
|
+
label_outer=Setting(boolean,
|
|
333
|
+
doc="Tick labels and axis titles of shared axes only on the outer "
|
|
334
|
+
"plots (bottom of each column, left of each row); default "
|
|
335
|
+
"`True` for groups from the figure's settings, `False` for "
|
|
336
|
+
"plots paired with `share_x=plot`."),
|
|
337
|
+
style=Setting(lambda v: _styles().check_style(v),
|
|
338
|
+
doc="A style name (`\"ggplot2\"`, …) or a dict of settings with an "
|
|
339
|
+
"optional `\"base\"`; default: the session style (`set_style`, "
|
|
340
|
+
"`use_style`), else `\"default\"`."),
|
|
341
|
+
vars=Setting(custom_vars, expr=False,
|
|
342
|
+
doc="Custom variables, read by `var(--name)` in any setting here and "
|
|
343
|
+
"below: `{\"accent\": \"#c00\"}`."),
|
|
344
|
+
scaling=Setting(number(positive=True),
|
|
345
|
+
doc="Multiplies every font size, line width, marker size, tick "
|
|
346
|
+
"length and pad of the style; default 1."),
|
|
347
|
+
**groups.Font.embed(),
|
|
348
|
+
text_color=Setting(color, inherited=True, doc="Text color."),
|
|
349
|
+
line_width=Setting(length(lo=0), inherited=True, doc="Default line width."),
|
|
350
|
+
**_kind_settings(),
|
|
351
|
+
color_variant=Setting(check_variant, inherited=True, doc=VARIANT_DOC),
|
|
352
|
+
plain_color=Setting(color, inherited=True,
|
|
353
|
+
doc="Color of a series that needs no hue (no levels, one "
|
|
354
|
+
"color, no `name`); unset: the plot's next color."),
|
|
355
|
+
opacity=Setting(number(0, 1), inherited=True, doc="Default opacity of series."),
|
|
356
|
+
rasterize=Setting(dpi_or_bool, inherited=True,
|
|
357
|
+
doc="`True` or a dpi: series drawn as images inside the PDF."),
|
|
358
|
+
simplify=Setting(simplify_value,
|
|
359
|
+
inherited=True,
|
|
360
|
+
doc="vec_io's `simplify` for every series: `\"auto\"` (default), `True`, a "
|
|
361
|
+
"dpi, `False`, or a `Simplify` (or a dict of its fields)."),
|
|
362
|
+
data=Setting(lambda v: v, inherited=True,
|
|
363
|
+
doc="A table whose columns the series' data arguments name."),
|
|
364
|
+
**groups.NumberFormat.embed(),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
#: Keywords accepted besides settings: ``title="…"`` sets the main title's text.
|
|
368
|
+
EXTRA = ("title",)
|
|
369
|
+
|
|
370
|
+
def __init__(self, **settings: "Unpack[FigureSettings]") -> None:
|
|
371
|
+
from .axis import Axis
|
|
372
|
+
self._areas: dict[Any, Any] = {} # id → Plot or Colorbar, in creation order
|
|
373
|
+
self._value_axes: list[Any] = [] # shared color and size axes
|
|
374
|
+
#: settings every plot's x (y) axis takes unless set on the axis itself
|
|
375
|
+
self.x_axis = Axis(self, "x")
|
|
376
|
+
self.y_axis = Axis(self, "y")
|
|
377
|
+
#: lines and bands through rows (``vline``, ``vspan``), groups of rows (``group``)
|
|
378
|
+
self.decorations: list[Any] = []
|
|
379
|
+
self.groups: list[Any] = []
|
|
380
|
+
self.title: FigureTitle = MainTitle(self)
|
|
381
|
+
self._titles: list[FigureTitle] = []
|
|
382
|
+
self._legends: list[Any] = []
|
|
383
|
+
self._colorbars: list[Any] = []
|
|
384
|
+
self._session_style = _styles().current()
|
|
385
|
+
self._theme_key: Any = None
|
|
386
|
+
self._theme_value: Theme = BASE_THEME
|
|
387
|
+
super().__init__(**settings)
|
|
388
|
+
|
|
389
|
+
def _width(self) -> Any:
|
|
390
|
+
"""Only ``height`` set: the width follows from ``aspect_ratio``; tracks: 480 px."""
|
|
391
|
+
if self.is_set("height") and not self.is_set("width"):
|
|
392
|
+
return _WIDTH_OF_HEIGHT
|
|
393
|
+
if self.tracks_layout():
|
|
394
|
+
return _TRACKS_WIDTH
|
|
395
|
+
return DECLINE
|
|
396
|
+
|
|
397
|
+
def _heights(self) -> Any:
|
|
398
|
+
"""Tracks: each row as high as its plot's ``height`` (px), other areas ``1fr``."""
|
|
399
|
+
if not self.tracks_layout() or not self._areas:
|
|
400
|
+
return DECLINE
|
|
401
|
+
out = []
|
|
402
|
+
for a in self._areas.values():
|
|
403
|
+
h = a.resolve("height") if "height" in getattr(a, "SETTINGS", {}) else None
|
|
404
|
+
if isinstance(h, tuple): # ("fr", share)
|
|
405
|
+
out.append(Dim(f"{h[1]!r}fr", h[1], "fr"))
|
|
406
|
+
elif isinstance(h, (int, float)):
|
|
407
|
+
out.append(Dim(f"{float(h)!r}px", float(h), "px"))
|
|
408
|
+
else:
|
|
409
|
+
out.append(_FR)
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
RULES = {"width": _width, "heights": _heights, "cols": _tracks_rule(1),
|
|
413
|
+
"spacing": _tracks_rule(0.0), "share_x": _tracks_rule("all")}
|
|
414
|
+
|
|
415
|
+
def tracks_layout(self) -> bool:
|
|
416
|
+
return self._values.get("layout") == "tracks"
|
|
417
|
+
|
|
418
|
+
def explicit_grid(self) -> list[list[Any]] | None:
|
|
419
|
+
"""The rows of ids of an explicit grid (``layout``), else ``None``."""
|
|
420
|
+
v = self.resolve("layout")
|
|
421
|
+
return v if isinstance(v, list) else None
|
|
422
|
+
|
|
423
|
+
def _children(self) -> list[Settings]:
|
|
424
|
+
return [self.title, *self._titles, *self._legends, *self._colorbars, *self._value_axes,
|
|
425
|
+
*(a for a in self._areas.values() if isinstance(a, Settings)),
|
|
426
|
+
*self.decorations, *self.groups]
|
|
427
|
+
|
|
428
|
+
def _theme(self) -> Theme:
|
|
429
|
+
styles = _styles()
|
|
430
|
+
style = self._values.get("style", self._session_style)
|
|
431
|
+
key = (id(style), styles.version())
|
|
432
|
+
if key != self._theme_key:
|
|
433
|
+
self._theme_value = styles.effective(style)
|
|
434
|
+
self._theme_key = key
|
|
435
|
+
return self._theme_value
|
|
436
|
+
|
|
437
|
+
def get(self, name: str) -> Any:
|
|
438
|
+
"""As :meth:`Settings.get`; ``"title"``: the title's text."""
|
|
439
|
+
if name == "title":
|
|
440
|
+
return self.title.get("text")
|
|
441
|
+
return super().get(name)
|
|
442
|
+
|
|
443
|
+
def set(self, **settings: "Unpack[FigureSettings]") -> Self: # type: ignore[override]
|
|
444
|
+
title = settings.pop("title", None)
|
|
445
|
+
super().set(**settings)
|
|
446
|
+
if title is not None:
|
|
447
|
+
self.title.set(text=title)
|
|
448
|
+
if self.explicit_grid() is not None and any(self.is_set(k) for k in ("rows", "cols")):
|
|
449
|
+
raise ValueError("give either a grid layout or rows/cols, not both")
|
|
450
|
+
if self.tracks_layout() and self.is_set("cols") and self._values["cols"] != 1:
|
|
451
|
+
raise ValueError("a tracks layout is one column of tracks: cols does not apply "
|
|
452
|
+
"(for tracks beside other plots, use a grid figure)")
|
|
453
|
+
if self.explicit_grid() is not None and self.resolve("fill_order") != "auto":
|
|
454
|
+
raise ValueError("fill_order applies to the automatic arrangement, not to a "
|
|
455
|
+
"grid")
|
|
456
|
+
if all(self.is_set(k) for k in ("width", "height", "aspect_ratio")):
|
|
457
|
+
raise ValueError("give at most two of width, height and aspect_ratio")
|
|
458
|
+
return self
|
|
459
|
+
|
|
460
|
+
def reference_size(self) -> tuple[float, float]:
|
|
461
|
+
"""Width and height (px) of the `reference` box: as given, or completed by
|
|
462
|
+
`aspect_ratio`."""
|
|
463
|
+
return self.resolve("width"), self.resolve("height")
|
|
464
|
+
|
|
465
|
+
def one_size_given(self) -> str | None:
|
|
466
|
+
"""``"width"`` or ``"height"`` when only that one is set (no `aspect_ratio`): plots
|
|
467
|
+
with a `data_ratio` then take the other side from it."""
|
|
468
|
+
if self.is_set("aspect_ratio"):
|
|
469
|
+
return None
|
|
470
|
+
w, h = self.is_set("width"), self.is_set("height")
|
|
471
|
+
return "width" if w and not h else ("height" if h and not w else None)
|
|
472
|
+
|
|
473
|
+
def __repr__(self) -> str:
|
|
474
|
+
n = len(self.plots)
|
|
475
|
+
style = self._values.get("style", self._session_style)
|
|
476
|
+
style = "default" if style is None else style
|
|
477
|
+
parts = [f"{n} plot{'' if n == 1 else 's'}",
|
|
478
|
+
f'style "{style}"' if isinstance(style, str) else "custom style"]
|
|
479
|
+
try:
|
|
480
|
+
w, h = self.size()
|
|
481
|
+
parts.insert(0, f"{round(w)} × {round(h)} px")
|
|
482
|
+
except Exception: # a figure that cannot lay out yet
|
|
483
|
+
pass
|
|
484
|
+
return f"Figure({', '.join(parts)})"
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def plots(self) -> list[Any]:
|
|
488
|
+
from .plot import Plot
|
|
489
|
+
return [a for a in self._areas.values() if isinstance(a, Plot)]
|
|
490
|
+
|
|
491
|
+
# -- areas -----------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
def _grid_ids(self) -> list[Any]:
|
|
494
|
+
ids: list[Any] = []
|
|
495
|
+
for r in self.explicit_grid() or []:
|
|
496
|
+
for k in r:
|
|
497
|
+
if k is not None and k != 0 and k not in ids:
|
|
498
|
+
ids.append(k)
|
|
499
|
+
return ids
|
|
500
|
+
|
|
501
|
+
def _capacity(self) -> float:
|
|
502
|
+
rows, cols = self.resolve("rows"), self.resolve("cols")
|
|
503
|
+
return rows * cols if rows and cols else math.inf
|
|
504
|
+
|
|
505
|
+
def _next_id(self) -> Any:
|
|
506
|
+
if self.explicit_grid() is not None:
|
|
507
|
+
for k in self._grid_ids():
|
|
508
|
+
if k not in self._areas:
|
|
509
|
+
return k
|
|
510
|
+
raise ValueError("every grid id already has a plot")
|
|
511
|
+
if len(self._areas) >= self._capacity():
|
|
512
|
+
raise ValueError(f"the {self.resolve('rows')} × {self.resolve('cols')} arrangement is full")
|
|
513
|
+
i = 1
|
|
514
|
+
while i in self._areas:
|
|
515
|
+
i += 1
|
|
516
|
+
return i
|
|
517
|
+
|
|
518
|
+
def _attach(self, area: Any, id: Any) -> Any:
|
|
519
|
+
if id is None:
|
|
520
|
+
id = self._next_id()
|
|
521
|
+
elif self.explicit_grid() is not None and id not in self._grid_ids():
|
|
522
|
+
raise ValueError(f"id {id!r} is not in the grid")
|
|
523
|
+
elif id in self._areas:
|
|
524
|
+
raise ValueError(f"id {id!r} is already taken")
|
|
525
|
+
elif self.explicit_grid() is None and len(self._areas) >= self._capacity():
|
|
526
|
+
raise ValueError(f"the {self.resolve('rows')} × {self.resolve('cols')} arrangement is full")
|
|
527
|
+
self._areas[id] = area
|
|
528
|
+
return id
|
|
529
|
+
|
|
530
|
+
def plot(self, id: Any = None, **settings: "Unpack[PlotSettings]") -> "Plot":
|
|
531
|
+
"""The plot with ``id``, created if needed; without ``id``, the next available
|
|
532
|
+
plot (a :class:`~plot_io.Track` in a tracks layout)."""
|
|
533
|
+
from .plot import Plot
|
|
534
|
+
|
|
535
|
+
if id is not None and id in self._areas:
|
|
536
|
+
p = self._areas[id]
|
|
537
|
+
if not isinstance(p, Plot):
|
|
538
|
+
raise ValueError(f"id {id!r} holds a colorbar")
|
|
539
|
+
if settings:
|
|
540
|
+
p.set(**settings)
|
|
541
|
+
return p
|
|
542
|
+
if self.tracks_layout():
|
|
543
|
+
return self.track(id=id, **settings)
|
|
544
|
+
return Plot(figure=self, id=id, **settings)
|
|
545
|
+
|
|
546
|
+
def track(self, id: Any = None, **settings: "Unpack[TrackSettings]") -> "Track":
|
|
547
|
+
"""A new, empty :class:`~plot_io.Track` (series added with its methods are
|
|
548
|
+
overlaid)."""
|
|
549
|
+
from .genome.layout import Track
|
|
550
|
+
return Track(figure=self, id=id, **settings)
|
|
551
|
+
|
|
552
|
+
def _new_plot(self, cls: Any, settings: dict[str, Any]) -> Any:
|
|
553
|
+
"""A new plot for a ``cls`` call made on the figure (``figure.scatter(…)``): the
|
|
554
|
+
settings of the plot (a track in a tracks layout) that ``cls`` lacks, ``title``,
|
|
555
|
+
``x_axis`` and ``y_axis``, are taken out of ``settings`` for it."""
|
|
556
|
+
from .settings import canonical
|
|
557
|
+
from .plot import Plot
|
|
558
|
+
kind: Any = Plot
|
|
559
|
+
if self.tracks_layout():
|
|
560
|
+
from .genome.layout import Track
|
|
561
|
+
kind = Track
|
|
562
|
+
own = getattr(cls, "SETTINGS", {})
|
|
563
|
+
plot_kw = {k: settings.pop(k) for k in list(settings)
|
|
564
|
+
if (k in kind.SETTINGS or k in Plot.CALL_EXTRA)
|
|
565
|
+
and canonical(k, own) not in own}
|
|
566
|
+
return self.plot(**plot_kw)
|
|
567
|
+
|
|
568
|
+
# -- figure decorations ----------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
def vline(self, x: Any, rows: Any = None,
|
|
571
|
+
**settings: "Unpack[FigureVLineSettings]") -> "FigureVLine":
|
|
572
|
+
"""A vertical line at ``x`` through the plots ``rows`` (plots or their series; all
|
|
573
|
+
by default) and the gaps between them, over the plots."""
|
|
574
|
+
from .rows import FigureVLine
|
|
575
|
+
d = FigureVLine(self, x, rows, **settings)
|
|
576
|
+
self.decorations.append(d)
|
|
577
|
+
return d
|
|
578
|
+
|
|
579
|
+
def vspan(self, x0: Any, x1: Any = None, rows: Any = None,
|
|
580
|
+
**settings: "Unpack[FigureVSpanSettings]") -> "FigureVSpan":
|
|
581
|
+
"""A vertical band from ``x0`` to ``x1`` (or over a genome region given as ``x0``)
|
|
582
|
+
through the plots ``rows``, under them."""
|
|
583
|
+
from .rows import FigureVSpan
|
|
584
|
+
d = FigureVSpan(self, x0, x1, rows, **settings)
|
|
585
|
+
self.decorations.append(d)
|
|
586
|
+
return d
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def titles(self) -> list[FigureTitle]:
|
|
590
|
+
"""The figure's titles: its main title first, then those of :meth:`add_title`."""
|
|
591
|
+
return [self.title, *self._titles]
|
|
592
|
+
|
|
593
|
+
def add_title(self, text: Any, plots: Any = None, side: Any = None,
|
|
594
|
+
**settings: Any) -> FigureTitle:
|
|
595
|
+
"""A title over ``plots`` (plots or their series; default every plot) on ``side``
|
|
596
|
+
(default ``"top"``); returns it. The plots' cells must fill the grid rectangle
|
|
597
|
+
they span."""
|
|
598
|
+
covered = self._covered("add_title", plots)
|
|
599
|
+
if side is not None:
|
|
600
|
+
settings["side"] = side
|
|
601
|
+
t = FigureTitle(self, covered, text=text, **settings)
|
|
602
|
+
self._titles.append(t)
|
|
603
|
+
return t
|
|
604
|
+
|
|
605
|
+
@property
|
|
606
|
+
def legends(self) -> list[Any]:
|
|
607
|
+
"""The figure's legends (:meth:`add_legend`), in creation order."""
|
|
608
|
+
return list(self._legends)
|
|
609
|
+
|
|
610
|
+
def add_legend(self, plots: Any = None, side: Any = None, **settings: Any) -> Any:
|
|
611
|
+
"""A legend of the entries of ``plots`` (plots or their series; default every
|
|
612
|
+
plot) on ``side`` (default ``"right"``), in the lanes beside them; returns it.
|
|
613
|
+
The plots' cells must fill the grid rectangle they span."""
|
|
614
|
+
from .plot import FigureLegend
|
|
615
|
+
covered = self._covered("add_legend", plots)
|
|
616
|
+
if side is not None:
|
|
617
|
+
settings["side"] = side
|
|
618
|
+
g = FigureLegend(self, covered, **settings)
|
|
619
|
+
self._legends.append(g)
|
|
620
|
+
return g
|
|
621
|
+
|
|
622
|
+
@property
|
|
623
|
+
def colorbars(self) -> list[Any]:
|
|
624
|
+
"""The figure's colorbars in the lanes (:meth:`add_colorbar`), in creation order."""
|
|
625
|
+
return list(self._colorbars)
|
|
626
|
+
|
|
627
|
+
def add_colorbar(self, source: Any = None, plots: Any = None, side: Any = None,
|
|
628
|
+
**settings: Any) -> Any:
|
|
629
|
+
"""A colorbar of the color axis of ``source`` (a mapped series or a color axis;
|
|
630
|
+
default: the last mapped series of the first of ``plots`` that has one) over
|
|
631
|
+
``plots`` (plots or their series; default every plot) on ``side`` (default
|
|
632
|
+
``"bottom"``), in the lanes beside them; returns it. The plots' cells must fill
|
|
633
|
+
the grid rectangle they span; their attached colorbars of the same axis are left
|
|
634
|
+
out."""
|
|
635
|
+
from .colorbar import FigureColorbar
|
|
636
|
+
covered = self._covered("add_colorbar", plots)
|
|
637
|
+
if side is not None:
|
|
638
|
+
settings["side"] = side
|
|
639
|
+
cb = FigureColorbar(self, source, covered, **settings)
|
|
640
|
+
self._colorbars.append(cb)
|
|
641
|
+
return cb
|
|
642
|
+
|
|
643
|
+
def _covered(self, what: str, plots: Any) -> list[Any] | None:
|
|
644
|
+
"""The plots ``plots`` names (``None``: every plot), checked to be this figure's."""
|
|
645
|
+
from .rows import plots_of
|
|
646
|
+
covered = plots_of(plots)
|
|
647
|
+
for p in covered or []:
|
|
648
|
+
if p.figure is not self:
|
|
649
|
+
raise ValueError(f"{what}: {p!r} is not a plot of this figure")
|
|
650
|
+
if covered is not None and not covered:
|
|
651
|
+
raise ValueError(f"{what}: plots= names no plot")
|
|
652
|
+
return covered
|
|
653
|
+
|
|
654
|
+
def group(self, first: Any, last: Any = None, **settings: Any) -> Any:
|
|
655
|
+
"""Plots ``first`` to ``last`` (plots or their series, in creation order) as a
|
|
656
|
+
group: ``label`` (rotated, left of their row labels), ``letter`` (a panel letter),
|
|
657
|
+
and the tick labels of a shared x axis under the last one."""
|
|
658
|
+
from .rows import FigureGroup, _plot_of
|
|
659
|
+
g = FigureGroup(self, _plot_of(first), _plot_of(last if last is not None else first),
|
|
660
|
+
**settings)
|
|
661
|
+
self.groups.append(g)
|
|
662
|
+
return g
|
|
663
|
+
|
|
664
|
+
def color_axis(self, **settings: "Unpack[ColorAxisSettings]") -> "ColorAxis":
|
|
665
|
+
"""A shared color axis: series given it as ``color_axis=`` map their values through
|
|
666
|
+
its gradient (``color_map``), scale and limits (``"auto"`` covers them all)."""
|
|
667
|
+
from .mapped import ColorAxis
|
|
668
|
+
return self.adopt_axis(ColorAxis(**settings))
|
|
669
|
+
|
|
670
|
+
def size_axis(self, **settings: "Unpack[SizeAxisSettings]") -> "SizeAxis":
|
|
671
|
+
"""A shared size axis (``size_axis=``): its sizes (``size_map``), scale and limits."""
|
|
672
|
+
from .mapped import SizeAxis
|
|
673
|
+
return self.adopt_axis(SizeAxis(**settings))
|
|
674
|
+
|
|
675
|
+
def adopt_axis(self, axis: Any) -> Any:
|
|
676
|
+
"""Make the figure the parent of a shared color or size axis (its style applies)."""
|
|
677
|
+
if axis._parent() is None:
|
|
678
|
+
axis._owner = self
|
|
679
|
+
self._value_axes.append(axis)
|
|
680
|
+
return axis
|
|
681
|
+
|
|
682
|
+
def colorbar_area(self, source: Any, id: Any = None, **settings: Any) -> Any:
|
|
683
|
+
"""A colorbar in its own grid area (a grid id, or the next position): the guide of
|
|
684
|
+
the color axis of ``source`` (a mapped series, or a color axis); see
|
|
685
|
+
:meth:`add_colorbar` for one in the lanes beside plots."""
|
|
686
|
+
from .colorbar import Colorbar
|
|
687
|
+
|
|
688
|
+
cb = Colorbar(source, figure=self, **settings)
|
|
689
|
+
cb.id = self._attach(cb, id)
|
|
690
|
+
return cb
|
|
691
|
+
|
|
692
|
+
def size_legend(self, source: Any, id: Any = None, **settings: Any) -> Any:
|
|
693
|
+
"""A size key in its own area (a grid id, or the next position): the guide of the
|
|
694
|
+
size axis of ``source`` (a series mapping sizes, or a size axis): its title, then
|
|
695
|
+
sample markers with their values; ``settings``: ``values``, ``title``,
|
|
696
|
+
``font_size`` (``.set`` changes them); returns it."""
|
|
697
|
+
from .legend import SizeLegend
|
|
698
|
+
|
|
699
|
+
sl = SizeLegend(source, self, **settings)
|
|
700
|
+
sl.id = self._attach(sl, id)
|
|
701
|
+
return sl
|
|
702
|
+
|
|
703
|
+
# -- output ----------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
@contextlib.contextmanager
|
|
706
|
+
def _region(self, region: Any) -> Generator[None]:
|
|
707
|
+
"""The figure drawn over another genome region (the x axes' ``region``)."""
|
|
708
|
+
with overriding(self.x_axis, region=region):
|
|
709
|
+
yield
|
|
710
|
+
|
|
711
|
+
def draw(self, canvas: Canvas, x: float = 0.0, y: float = 0.0,
|
|
712
|
+
region: Any = None) -> tuple[float, float]:
|
|
713
|
+
"""Draw the figure on a canvas (a vec_io page, a
|
|
714
|
+
:class:`~plot_io.canvas.Recording`) with its top-left corner at ``(x, y)`` (page
|
|
715
|
+
units); returns its size in px. ``region``: a genome region drawn instead of
|
|
716
|
+
the x axes' (as ``save``)."""
|
|
717
|
+
from . import render
|
|
718
|
+
with self._region(region):
|
|
719
|
+
return render.draw(self, canvas, x, y)
|
|
720
|
+
|
|
721
|
+
def size(self, region: Any = None) -> tuple[float, float]:
|
|
722
|
+
"""Width and height of the figure in px."""
|
|
723
|
+
from . import render
|
|
724
|
+
with self._region(region):
|
|
725
|
+
return render.size(self)
|
|
726
|
+
|
|
727
|
+
def save(self, file: Any, *, region: Any = None, format: str | None = None,
|
|
728
|
+
**options: "Unpack[WriteOptions]") -> None:
|
|
729
|
+
"""Write the figure to ``file``, a path or a binary file object, as :meth:`write`
|
|
730
|
+
does, in ``format`` (``"pdf"``, ``"eps"``, ``"svg"``, ``"png"``, ``"jpeg"``,
|
|
731
|
+
``"jxl"``, ``"avif"``) or the path's extension (none, or another: ``ValueError``)."""
|
|
732
|
+
from . import render
|
|
733
|
+
with self._region(region):
|
|
734
|
+
render.save(self, file, format=format, **options)
|
|
735
|
+
|
|
736
|
+
def save_each(self, regions: Any, file: str,
|
|
737
|
+
**options: "Unpack[WriteOptions]") -> list[str]:
|
|
738
|
+
"""Write the figure once per region (as :meth:`save` with ``region=``) to ``file``
|
|
739
|
+
with ``{chr}``, ``{start}`` (1-based), ``{end}``, ``{name}`` and ``{i}`` (from 0)
|
|
740
|
+
filled in; ``regions``: a list of regions, or a mapping of names to regions (else
|
|
741
|
+
a region's name is its text, ``chr2:1,000-2,000``). Returns the paths written."""
|
|
742
|
+
from .genome.regions import parse_region
|
|
743
|
+
from collections.abc import Mapping
|
|
744
|
+
named: list[tuple[str | None, Any]]
|
|
745
|
+
if isinstance(regions, Mapping):
|
|
746
|
+
named = [(str(k), parse_region(v)) for k, v in regions.items()]
|
|
747
|
+
elif isinstance(regions, (list, tuple)) and not isinstance(regions, str):
|
|
748
|
+
named = [(None, parse_region(v)) for v in regions]
|
|
749
|
+
else:
|
|
750
|
+
raise ValueError(f"save_each: expected a list of regions or a mapping of names to "
|
|
751
|
+
f"regions, got {regions!r}")
|
|
752
|
+
paths = []
|
|
753
|
+
for i, (name, r) in enumerate(named):
|
|
754
|
+
paths.append(str(file).format(chr=r.chr, start=r.start + 1, end=r.end, i=i,
|
|
755
|
+
name=str(r) if name is None else name))
|
|
756
|
+
if len(set(paths)) < len(paths):
|
|
757
|
+
raise ValueError(f"save_each: {file!r} gives two regions the same path (use "
|
|
758
|
+
f"{{name}} or {{i}})")
|
|
759
|
+
for path, (_, r) in zip(paths, named):
|
|
760
|
+
self.save(path, region=r, **options)
|
|
761
|
+
return paths
|
|
762
|
+
|
|
763
|
+
def write(self, format: str, *, region: Any = None,
|
|
764
|
+
**options: "Unpack[WriteOptions]") -> bytes:
|
|
765
|
+
"""The figure as ``format`` bytes: ``"pdf"``, ``"eps"``, ``"svg"``, ``"png"``,
|
|
766
|
+
``"jpeg"`` (``"jpg"``), ``"jxl"`` or ``"avif"``.
|
|
767
|
+
|
|
768
|
+
``options`` are :meth:`vec_io.Document.write`'s (option objects or dicts of their
|
|
769
|
+
fields: ``flatten``, ``images``, ``png``, ``jpeg``, ``jxl``, ``avif``; and
|
|
770
|
+
``compression``, ``font_hinting``, …) but its ``view`` and ``pages``, vec_io
|
|
771
|
+
``Document`` arguments (``title``, ``creation_date``, …; ``title`` by default the plain
|
|
772
|
+
text of the main title, else of the one plot's title; ``None``: none), and plot_io's:
|
|
773
|
+
``dpi`` (dots per inch, or ``"2x"``: images rendered at it; PDF, EPS and SVG images
|
|
774
|
+
resampled to it), ``default_dpi`` (images without ``dpi``; default the figure's),
|
|
775
|
+
``background`` (images: in place of the figure's; ``None``: transparent),
|
|
776
|
+
``quality`` (the figure's by default; into ``images`` or the image format's option
|
|
777
|
+
unless it sets its own), ``simplify`` (the figure's for this output) and ``fonts``
|
|
778
|
+
(a vec_io ``Fonts`` or a dict; default the figure's). ``region``: a genome region
|
|
779
|
+
drawn instead of the x axes' ``region`` (the data are read for it).
|
|
780
|
+
"""
|
|
781
|
+
from . import render
|
|
782
|
+
with self._region(region):
|
|
783
|
+
return render.write(self, format, **options)
|
|
784
|
+
|
|
785
|
+
def _repr_mimebundle_(self, include: Any = None, exclude: Any = None) -> dict[str, str]:
|
|
786
|
+
"""The figure as SVG for notebooks (``image/svg+xml`` only)."""
|
|
787
|
+
from . import render
|
|
788
|
+
return render.repr_svg(self, None, include, exclude)
|