plot-io 0.12.0__py3-none-win_arm64.whl

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