plot-io 0.12.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/shapes.py ADDED
@@ -0,0 +1,66 @@
1
+ """Many rectangles or polygons in one call (vec_io's ``draw_rects`` and ``draw_polygons``)."""
2
+
3
+ from array import array
4
+ from typing import Any
5
+ from .canvas import Canvas
6
+
7
+ __all__ = ["draw_rects", "draw_polygons"]
8
+
9
+
10
+ def draw_rects(canvas: Canvas, x: Any, y: Any, w: Any, h: Any, fill: Any = None,
11
+ stroke: Any = None, stroke_width: Any = None, simplify: Any = None) -> None:
12
+ """Rectangles with top-left corners ``(x, y)`` and sizes ``(w, h)`` (page units);
13
+ ``fill``, ``stroke`` and ``stroke_width``: one value or one per rectangle."""
14
+ if not len(x):
15
+ return
16
+ canvas.draw_rects(x, y, w, h, fill=fill, stroke=stroke, stroke_width=stroke_width,
17
+ simplify=simplify)
18
+
19
+
20
+ def draw_polygons(canvas: Canvas, polygons: list[list[tuple[Any, Any]]], fill: Any = None,
21
+ stroke: Any = None, stroke_width: Any = None, fill_rule: str = "nonzero",
22
+ simplify: Any = None) -> None:
23
+ """Polygons, each a list of rings ``(xs, ys)`` filled together (holes work);
24
+ ``fill``, ``stroke`` and ``stroke_width``: one value or one per polygon."""
25
+ if not polygons:
26
+ return
27
+ xs, ys = array("d"), array("d")
28
+ ring_starts, path_starts = array("q"), array("q")
29
+ for poly in polygons:
30
+ path_starts.append(len(ring_starts))
31
+ for rx, ry in poly:
32
+ ring_starts.append(len(xs))
33
+ xs.extend(rx)
34
+ ys.extend(ry)
35
+ ring_starts.append(len(xs))
36
+ path_starts.append(len(ring_starts) - 1)
37
+ canvas.draw_polygons((xs, ys, ring_starts, path_starts), fill=fill, stroke=stroke,
38
+ stroke_width=stroke_width, fill_rule=fill_rule, simplify=simplify)
39
+
40
+
41
+ def fill_ring_sets(canvas: Canvas, ring_sets: list[tuple[Any, Any, Any]], fill: Any,
42
+ simplify: Any = None) -> None:
43
+ """Paths given as vec_io ring arrays ``(xs, ys, ring_starts)`` (one path each), one fill
44
+ per path."""
45
+ xs, ys = array("d"), array("d")
46
+ ring_starts, path_starts = array("q"), array("q")
47
+ for rx, ry, starts in ring_sets:
48
+ path_starts.append(len(ring_starts))
49
+ base = len(xs)
50
+ ring_starts.extend(base + s for s in starts[:-1])
51
+ xs.extend(rx)
52
+ ys.extend(ry)
53
+ if not len(ring_starts):
54
+ return
55
+ ring_starts.append(len(xs))
56
+ path_starts.append(len(ring_starts) - 1)
57
+ canvas.draw_polygons((xs, ys, ring_starts, path_starts), fill=fill, fill_rule="nonzero",
58
+ simplify=simplify)
59
+
60
+
61
+ def swatch(x: float, y: float, w: float, h: float) -> tuple[float, float, float, float]:
62
+ """The rectangle legend symbols of filled series use in a symbol box ``(x, y, w, h)``:
63
+ 0.6em high with the default row height (the box is 0.8 of the row, 1.35em), as wide
64
+ as the box, like line symbols."""
65
+ sh = 0.6 / 1.08 * h
66
+ return x, y + (h - sh) / 2, w, sh
plot_io/sharing.py ADDED
@@ -0,0 +1,232 @@
1
+ """Shared axes: plots whose x (or y) axes form a group have one scale, one set of limits,
2
+ the same ticks and category order, and (``label_outer``) tick labels and titles only on
3
+ the outer plots of each column (x) or row (y).
4
+
5
+ Groups come from the figure's ``share_x``/``share_y``/``share`` (all plots, plots of a
6
+ column span, plots of a row span, or the plots holding a plot type: ``share_y="bigwig"``)
7
+ and from plots' own ``share_x=other`` (chained). The figure's ``share_color`` and
8
+ ``share_size`` group plots the same way for their series' color and size axes
9
+ (:func:`plot_io.mapped.train`). The last plot of
10
+ a figure group (``figure.group``) shows the x tick labels too, under ``label_outer``.
11
+ """
12
+
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ __all__ = ["check_figure_share", "check_legend_share", "check_share", "groups", "leader", "apply"]
17
+
18
+ _MODES = ("all", "col", "row")
19
+
20
+
21
+ def _check(v: Any, types: bool, grid: bool = False) -> Any:
22
+ """A sharing mode: ``False``, ``True``/``"all"``, ``"col"``, ``"row"``; with ``types``
23
+ a plot type's name, with ``grid`` ``"grid"``."""
24
+ if v is False or v is True:
25
+ return "all" if v else False
26
+ s = v.strip() if isinstance(v, str) else None
27
+ if s is not None and (s.lower() in _MODES or (grid and s.lower() == "grid")):
28
+ return s.lower()
29
+ if s is not None and types:
30
+ from .layers import layer_types
31
+ if s in layer_types():
32
+ return s
33
+ kinds = "False, True, 'all', 'col', 'row'" + (", 'grid'" if grid else "")
34
+ tail = " or a plot type's name" if types else ""
35
+ raise ValueError(f"expected {kinds}{tail}, got {v!r}")
36
+
37
+
38
+ def check_figure_share(v: Any) -> Any:
39
+ """``False``, ``True``/``"all"``, ``"col"``, ``"row"`` or a plot type's name (the
40
+ plots holding one: ``"bigwig"``)."""
41
+ return _check(v, True)
42
+
43
+
44
+ def check_legend_share(v: Any) -> Any:
45
+ """The figure's ``share_legend``: ``False``, ``True``/``"all"``, ``"row"`` or ``"col"``."""
46
+ return _check(v, False)
47
+
48
+
49
+ def check_share(v: Any) -> Any:
50
+ """The figure's ``share``: as ``share_x`` or ``"grid"`` (x per column, y per row)."""
51
+ return _check(v, True, grid=True)
52
+
53
+
54
+ def figure_modes(fig: Any) -> dict[str, Any]:
55
+ """The figure's sharing mode per axis role."""
56
+ share = fig.resolve("share")
57
+ out: dict[str, Any] = {"x": False, "y": False}
58
+ if share == "grid":
59
+ out = {"x": "col", "y": "row"}
60
+ elif share:
61
+ out = {"x": share, "y": share}
62
+ for role in ("x", "y"):
63
+ v = fig.resolve(f"share_{role}")
64
+ if v is not None:
65
+ out[role] = v
66
+ return out
67
+
68
+
69
+ def _holds(plot: Any, kind: str) -> bool:
70
+ """Whether a plot holds a series of the plot type named ``kind``."""
71
+ from .layers import layer_types
72
+ cls = layer_types().get(kind)
73
+ return isinstance(cls, type) and any(isinstance(s, cls) for s in plot.series)
74
+
75
+
76
+ class _Union:
77
+ def __init__(self, items: list[Any]) -> None:
78
+ self.parent = {id(p): id(p) for p in items}
79
+
80
+ def find(self, k: int) -> int:
81
+ while self.parent[k] != k:
82
+ self.parent[k] = self.parent[self.parent[k]]
83
+ k = self.parent[k]
84
+ return k
85
+
86
+ def join(self, a: Any, b: Any) -> None:
87
+ ra, rb = self.find(id(a)), self.find(id(b))
88
+ if ra != rb:
89
+ self.parent[rb] = ra
90
+
91
+
92
+ def groups(fig: Any, areas: list[Any], role: str) -> list[tuple[list[Any], bool]]:
93
+ """Groups of areas sharing axis ``role`` (``"x"``, ``"y"``, or ``"color"``/``"size"``:
94
+ their series' color or size axes; ``"legend"``: one figure legend), two or more each,
95
+ with whether the group comes from the figure's setting (outer labels by default)."""
96
+ from .plot import Plot
97
+
98
+ mapped = role in ("color", "size", "legend")
99
+ plots = [a for a in areas if isinstance(a.obj, Plot) and (mapped or a.obj.resolve("axes"))]
100
+ if not plots:
101
+ return []
102
+ mode = fig.resolve(f"share_{role}") if mapped else figure_modes(fig)[role]
103
+ u = _Union(plots)
104
+ own = {id(a): None if mapped else a.obj.own_share(role) for a in plots}
105
+ figure_made: set[int] = set()
106
+ if mode:
107
+ free = [a for a in plots if own[id(a)] is None]
108
+ keys: dict[int, Any]
109
+ if mode == "all":
110
+ keys = {id(a): 0 for a in free}
111
+ elif mode == "col":
112
+ keys = {id(a): (a.c0, a.c1) for a in free}
113
+ elif mode == "row":
114
+ keys = {id(a): (a.r0, a.r1) for a in free}
115
+ else: # the plots holding a plot type
116
+ free = [a for a in free if _holds(a.obj, mode)]
117
+ keys = {id(a): 0 for a in free}
118
+ first: dict[Any, Any] = {}
119
+ for a in free:
120
+ k = keys[id(a)]
121
+ if k in first:
122
+ u.join(first[k], a)
123
+ else:
124
+ first[k] = a
125
+ figure_made = {id(a) for a in free}
126
+ by_plot = {id(a.obj): a for a in plots}
127
+ for a in plots:
128
+ other = own[id(a)]
129
+ if other is not None:
130
+ b = by_plot.get(id(other))
131
+ if b is None:
132
+ raise ValueError(f"{a.obj!r} shares its {role} axis with {other!r}, which is "
133
+ f"not a plot of the same figure")
134
+ u.join(b, a)
135
+ out: dict[int, list[Any]] = {}
136
+ for a in plots:
137
+ out.setdefault(u.find(id(a)), []).append(a)
138
+ result = []
139
+ for members in out.values():
140
+ if len(members) > 1:
141
+ result.append((members, all(id(m) in figure_made for m in members)))
142
+ return result
143
+
144
+
145
+ def leader(axes: list[Any], role: str,
146
+ given: Callable[[Any, str], bool] | None = None) -> int:
147
+ """Index of the axis whose scale and limits a shared group takes: one with limits, else
148
+ one with a scale, else the first. Different limits or scales are an error. ``given(axis,
149
+ name)``: whether a setting is given (default: set on the axis)."""
150
+ is_given = given or (lambda a, name: a.is_set(name))
151
+ what = f"{role} axes" if role in ("color", "size") else f"{role} axis"
152
+ limited = [a for a in axes if a.resolve("limits") != "auto"]
153
+ specs = {repr(a.resolve("limits")) for a in limited}
154
+ if len(specs) > 1:
155
+ raise ValueError(f"plots sharing their {what} have different limits: "
156
+ f"{', '.join(sorted(specs))}")
157
+ scaled = [a for a in axes if is_given(a, "scale")]
158
+ scales = {a.resolve("scale") for a in scaled}
159
+ if len(scales) > 1:
160
+ raise ValueError(f"plots sharing their {what} have different scales: "
161
+ f"{', '.join(sorted(map(str, scales)))}")
162
+ for pick in (limited, scaled):
163
+ if pick:
164
+ return axes.index(pick[0])
165
+ return 0
166
+
167
+
168
+ def _leader(members: list[Any], role: str) -> Any:
169
+ return members[leader([getattr(m.obj, f"{role}_axis") for m in members], role)]
170
+
171
+
172
+ def _check_times(members: list[Any], role: str) -> None:
173
+ """Refuse a group of plots some of which draw dates on the axis and some numbers."""
174
+ kinds = set()
175
+ for m in members:
176
+ on = [s for s in m.state.role_items[role] if s.has_axis(role)]
177
+ if on and all(s.categories(role) is None for s in on):
178
+ kinds.add(any(getattr(s, "time_role", lambda r: None)(role) for s in on))
179
+ if len(kinds) > 1:
180
+ raise ValueError(f"plots sharing their {role} axis draw dates and numbers on it: a "
181
+ f"time axis would read the numbers as seconds")
182
+
183
+
184
+ def apply(fig: Any, areas: list[Any]) -> None:
185
+ """Resolve every group: one scale and set of limits from all members' series (category
186
+ order in plot order), ticks shared, and outer labels."""
187
+ label_outer = fig.resolve("label_outer")
188
+ for role in ("x", "y"):
189
+ for members, from_figure in groups(fig, areas, role):
190
+ lead = _leader(members, role)
191
+ items = []
192
+ for m in members:
193
+ items.extend(m.state.role_items[role])
194
+ lstate = getattr(lead.state, role)
195
+ if lstate.axis.resolve("scale") is None:
196
+ _check_times(members, role)
197
+ try:
198
+ lstate.resolve(items, lead.state.padded)
199
+ except ValueError as e:
200
+ if len(members) < 2 or "mixes categorical and numeric" not in str(e):
201
+ raise
202
+ raise ValueError(f"plots sharing their {role} axis (share, share_{role}) draw "
203
+ f"categories on one and numbers on another: share only plots "
204
+ f"of one kind") from None
205
+ leaves = any(getattr(m.state, role).call_leaves for m in members)
206
+ for m in members:
207
+ st = getattr(m.state, role)
208
+ st.call_leaves = leaves # a dendrogram's leaves name them all
209
+ st.shared = lstate if m is not lead else None
210
+ if m is not lead:
211
+ st.copy_limits(lstate)
212
+ outer = label_outer if label_outer is not None else from_figure
213
+ if not outer:
214
+ continue
215
+ ends = {id(g.end()) for g in fig.groups} if role == "x" else set()
216
+ for m in members:
217
+ st = getattr(m.state, role)
218
+ bottom = _is_outer(m, members, role)
219
+ st.hide_labels = not bottom and id(m.obj) not in ends
220
+ st.hide_title = not bottom
221
+
222
+
223
+ def _is_outer(a: Any, members: list[Any], role: str) -> bool:
224
+ if role == "x":
225
+ same = [m for m in members if (m.c0, m.c1) == (a.c0, a.c1)]
226
+ if getattr(a.state, "x").on_far_side():
227
+ return a.r0 == min(m.r0 for m in same)
228
+ return a.r1 == max(m.r1 for m in same)
229
+ same = [m for m in members if (m.r0, m.r1) == (a.r0, a.r1)]
230
+ if getattr(a.state, "y").on_far_side():
231
+ return a.c1 == max(m.c1 for m in same)
232
+ return a.c0 == min(m.c0 for m in same)
plot_io/sizemaps.py ADDED
@@ -0,0 +1,97 @@
1
+ """Size maps: the range of marker diameters mapped values are drawn with (their limits and
2
+ scale are a size axis', :mod:`plot_io.mapped`)."""
3
+
4
+ import math
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from . import kernels
9
+ from .settings import one_of
10
+
11
+ __all__ = ["SizeMap", "DEFAULT"]
12
+
13
+
14
+ def _size(v: Any) -> Any:
15
+ """A marker diameter: px or any length (``"1.2em"``), resolved by the series using
16
+ the map."""
17
+ from .validators import length
18
+ return length(lo=0)(v)
19
+
20
+
21
+ def _size_px(v: Any, owner: Any) -> float:
22
+ if isinstance(v, (int, float)) and (owner is None or v != v):
23
+ return float(v)
24
+ if owner is None:
25
+ raise ValueError(f"SizeMap size {v!r}: a relative length needs the series it is "
26
+ f"drawn for (owner=)")
27
+ from .units import resolve_raw
28
+ from .validators import length
29
+ return resolve_raw(owner, "size", length(lo=0), v)
30
+
31
+
32
+ class SizeMap:
33
+ """Marker diameters (px) over [0, 1]: the positions of values between their size axis'
34
+ limits.
35
+
36
+ ``sizes``: the diameters at the two limits. ``by="area"`` moves the marker area
37
+ linearly between the end areas; ``by="diameter"`` moves the diameter. ``under`` and
38
+ ``over``: sizes below and above the limits (default: the end sizes; ``"none"`` leaves
39
+ those points out); ``bad``: size of points with a missing value (default: left out).
40
+
41
+ With ``sizes=(0, d)``, ``by="area"`` and ``size_limits="0/max"``, a marker's area is
42
+ proportional to its value. ``size_map`` also takes a pair of diameters.
43
+ """
44
+
45
+ def __init__(self, sizes: Sequence[float] = (3.0, 13.0), by: str = "area",
46
+ under: Any = None, over: Any = None, bad: Any = None) -> None:
47
+ if len(sizes) != 2:
48
+ raise ValueError("sizes must be a pair (smallest, largest)")
49
+ self.sizes = (_size(sizes[0]), _size(sizes[1]))
50
+ self.by = one_of("area", "diameter")(by)
51
+ self.under = self._extreme(under)
52
+ self.over = self._extreme(over)
53
+ self.bad = self._extreme(bad)
54
+
55
+ @staticmethod
56
+ def _extreme(v: Any) -> Any:
57
+ if v is None:
58
+ return None
59
+ if isinstance(v, str) and v.lower() == "none":
60
+ return math.nan
61
+ return _size(v)
62
+
63
+ def __repr__(self) -> str:
64
+ from .units import given
65
+ return f"SizeMap({given(self.sizes[0])!r}–{given(self.sizes[1])!r}, by {self.by})"
66
+
67
+ def diameters(self, owner: Any = None) -> tuple[float, float]:
68
+ """The end sizes in px, resolved for series ``owner`` (its `em`, scaling)."""
69
+ return _size_px(self.sizes[0], owner), _size_px(self.sizes[1], owner)
70
+
71
+ def map(self, values: Any, flo: float, fhi: float, function: str | None,
72
+ owner: Any = None) -> Any:
73
+ """Diameters (px) for ``values``, with ``flo``/``fhi`` the limits after the scale
74
+ function; lengths resolved for series ``owner``."""
75
+ d0, d1 = self.diameters(owner)
76
+ span = fhi - flo
77
+ nan = math.nan
78
+ under = d0 if self.under is None else _size_px(self.under, owner)
79
+ over = d1 if self.over is None else _size_px(self.over, owner)
80
+ bad = nan if self.bad is None else _size_px(self.bad, owner)
81
+ if self.by == "diameter":
82
+ k = (d1 - d0) / span if span else 0.0
83
+ lo, hi = (d0, d1) if d1 >= d0 else (d1, d0)
84
+ u, o = (under, over) if d1 >= d0 else (over, under)
85
+ return kernels.scaled(values, k, d0 - k * flo, function, low=lo, high=hi,
86
+ under=u, over=o, nan=bad)
87
+ a0, a1 = d0 * d0, d1 * d1
88
+ k = (a1 - a0) / span if span else 0.0
89
+ lo, hi = (a0, a1) if a1 >= a0 else (a1, a0)
90
+ u, o = (under * under, over * over) if a1 >= a0 else (over * over, under * under)
91
+ areas = kernels.scaled(values, k, a0 - k * flo, function, low=lo, high=hi,
92
+ under=u, over=o, nan=bad * bad)
93
+ return kernels.scaled(areas, 1.0, 0.0, "sqrt")
94
+
95
+
96
+ #: The sizes of size axes without a ``size_map``.
97
+ DEFAULT = SizeMap()
plot_io/spec.py ADDED
@@ -0,0 +1,240 @@
1
+ """Renders: the model's settings resolved during one render.
2
+
3
+ A render (:func:`rendering`) resolves a setting of an object on its first plain read
4
+ (``obj.resolve(name)``) and keeps the value for the rest of the render: a dict per object
5
+ (:meth:`Render.lookup`). A setting never read is never resolved. The render also holds
6
+ what the renderer computes about the model: the layout boxes that ``%`` of the axes refers
7
+ to, the plot's colors given to series without one, and the trained color and size axes.
8
+
9
+ A setting whose value needs what only its use provides (``%`` of a layout box, of a slot,
10
+ an axis scale) is :data:`LAZY` in the render, and resolved where it is used, against the
11
+ render's boxes.
12
+ """
13
+
14
+ import contextlib
15
+ import contextvars
16
+ import warnings
17
+ from collections.abc import Generator
18
+ from typing import Any
19
+
20
+ from .units import NeedsContext
21
+
22
+ __all__ = ["LAZY", "NeedsBox", "Render", "rendering", "current", "set_boxes", "auto_colors",
23
+ "warn", "warnings_once"]
24
+
25
+
26
+ class _Lazy:
27
+ __slots__ = ()
28
+
29
+ def __repr__(self) -> str:
30
+ return "LAZY"
31
+
32
+
33
+ #: The value of a setting resolved where it is used (it needs a layout box, a slot, a
34
+ #: scale).
35
+ LAZY: Any = _Lazy()
36
+ _MISS = object()
37
+ #: The values of an object outside the render's figure: none kept.
38
+ _OUTSIDE: dict[str, Any] = {}
39
+
40
+
41
+ class NeedsBox(NeedsContext):
42
+ """``%`` of a layout box, resolved before the box is known."""
43
+
44
+
45
+ class Render:
46
+ """One render of a figure: the resolved settings of its objects, and what the renderer
47
+ computes about them (layout boxes, the plot's colors)."""
48
+
49
+ __slots__ = ("root", "theme", "values", "held", "tree", "boxes", "colors", "plots", "calls",
50
+ "placed", "widths", "scales", "size_keys", "runs", "levels",
51
+ "levels_group")
52
+
53
+ def __init__(self, root: Any) -> None:
54
+ self.root = root
55
+ self.theme = root._theme()
56
+ #: ``id(obj)`` → ``{name: value}``: the settings of ``obj`` read in this render.
57
+ self.values: dict[int, dict[str, Any]] = {}
58
+ #: the objects of ``values`` (kept alive, so that their ids stay theirs).
59
+ self.held: list[Any] = []
60
+ #: the ids of the objects of the figure (made on demand, see :meth:`changed`).
61
+ self.tree: set[int] | None = None
62
+ #: ``id(obj)`` → ``{"axes_width": px, …}``: the boxes `%` refers to.
63
+ self.boxes: dict[int, dict[str, float]] = {}
64
+ #: ``id(series)`` → the plot's colors it takes (``None``: it has its own).
65
+ self.colors: dict[int, list[Any] | None] = {}
66
+ #: level axes' colors (``(color group, axis key)`` → ``(plot id, {level: color})``)
67
+ #: and each plot's color group (``share_color``), see :mod:`plot_io.levels`
68
+ self.levels: dict[Any, Any] = {}
69
+ self.levels_group: dict[int, int] = {}
70
+ #: ``id(plot)`` → its calls (layers built, positions applied), ``id(series)`` → its
71
+ #: call (see :mod:`plot_io.layers.pipeline`).
72
+ self.plots: dict[int, list[Any]] = {}
73
+ self.calls: dict[int, Any] = {}
74
+ #: ``id(plot)`` → a drawing context (no canvas) of its axes as the last layout round
75
+ #: placed them: what stats binned at the axes' resolution read (hexagon lattices).
76
+ self.placed: dict[int, Any] = {}
77
+ #: ``id(plot)`` → the axes width (px) genome data were binned for.
78
+ self.widths: dict[int, float] = {}
79
+ #: ``id(axis)`` → the trained state of a color or size axis (see
80
+ #: :func:`plot_io.mapped.train`).
81
+ self.scales: dict[int, Any] = {}
82
+ #: the size keys of the trained size axes (see :func:`plot_io.mapped.size_keys`).
83
+ self.size_keys: dict[Any, Any] = {}
84
+ #: shaped lines of rich content by font database, font and content (see
85
+ #: :func:`plot_io.text.shape`; plain strings are memoized in the font database)
86
+ self.runs: dict[Any, Any] = {}
87
+
88
+ def lookup(self, obj: Any, name: str) -> Any:
89
+ """The resolved value of ``obj``'s setting ``name`` (resolved on its first read in
90
+ the render), or :data:`LAZY` (resolved where used, or ``obj`` is not of the
91
+ render's figure). Markup in a text setting is checked when it is resolved."""
92
+ d = self.values.get(id(obj))
93
+ if d is None:
94
+ d = self.values[id(obj)] = {} if self._holds(obj) else _OUTSIDE
95
+ self.held.append(obj)
96
+ v = d.get(name, _MISS)
97
+ if v is _MISS:
98
+ if d is _OUTSIDE:
99
+ return LAZY
100
+ v = d[name] = self._compute(obj, name)
101
+ return v
102
+
103
+ def _holds(self, obj: Any) -> bool:
104
+ """Whether ``obj`` is of the render's figure (not an object being made)."""
105
+ try:
106
+ return obj._root() is self.root
107
+ except AttributeError:
108
+ return False
109
+
110
+ def changed(self, obj: Any) -> None:
111
+ """``obj`` was set during the render: when it is of the figure (not an object being
112
+ made by the render), drop what the render resolved and computed from the model."""
113
+ if id(obj) not in self.values:
114
+ if not self._holds(obj):
115
+ return
116
+ if self.tree is None:
117
+ self.tree = set()
118
+ todo = [self.root]
119
+ while todo:
120
+ o = todo.pop()
121
+ if id(o) not in self.tree:
122
+ self.tree.add(id(o))
123
+ todo.extend(o._children())
124
+ if id(obj) not in self.tree:
125
+ return
126
+ self.values.clear()
127
+ self.plots.clear()
128
+ self.calls.clear()
129
+
130
+ def _compute(self, obj: Any, name: str) -> Any:
131
+ # Kept values do not depend on the layout: the boxes are hidden while one is
132
+ # resolved, so that `%` of a box stays LAZY.
133
+ boxes, widths = self.boxes, self.widths
134
+ try:
135
+ if boxes or widths:
136
+ self.boxes, self.widths = {}, {}
137
+ try:
138
+ v = obj._resolve(name, theme=self.theme)
139
+ finally:
140
+ self.boxes, self.widths = boxes, widths
141
+ else:
142
+ v = obj._resolve(name, theme=self.theme)
143
+ except NeedsContext:
144
+ return LAZY
145
+ texts = _TEXTS.get(type(obj))
146
+ if texts is None:
147
+ texts = _texts(type(obj))
148
+ if name in texts and v is not None and self.lookup(obj, "markup"):
149
+ from .markup import check
150
+ try:
151
+ check(v)
152
+ except ValueError as e:
153
+ raise ValueError(f"{obj.kind} setting {name!r}: {e}") from None
154
+ return v
155
+
156
+
157
+ _TEXTS: dict[type, frozenset[str]] = {}
158
+
159
+
160
+ def _texts(cls: type) -> frozenset[str]:
161
+ """The text settings of settings class ``cls`` whose markup is checked (none without
162
+ a ``markup`` setting)."""
163
+ t = _TEXTS.get(cls)
164
+ if t is None:
165
+ from .validators import text
166
+ names = cls.SETTINGS # type: ignore[attr-defined]
167
+ t = _TEXTS[cls] = frozenset(n for n, s in names.items() if s.check is text) \
168
+ if "markup" in names else frozenset()
169
+ return t
170
+
171
+
172
+ _CURRENT: contextvars.ContextVar[Render | None] = contextvars.ContextVar("plot_io_render",
173
+ default=None)
174
+
175
+
176
+ def current() -> Render | None:
177
+ """The render in progress, if any."""
178
+ return _CURRENT.get()
179
+
180
+
181
+ @contextlib.contextmanager
182
+ def rendering(root: Any) -> Generator[Render]:
183
+ """A render of ``root`` (a figure). Inside a render of the same figure, that
184
+ render."""
185
+ r = _CURRENT.get()
186
+ if r is not None and r.root is root:
187
+ yield r
188
+ return
189
+ r = Render(root)
190
+ token = _CURRENT.set(r)
191
+ try:
192
+ yield r
193
+ finally:
194
+ _CURRENT.reset(token)
195
+
196
+
197
+ _PENDING: contextvars.ContextVar[dict[Any, tuple[str, type[Warning]]] | None] = \
198
+ contextvars.ContextVar("plot_io_warnings", default=None)
199
+
200
+
201
+ def warn(message: str, key: Any = None, category: type[Warning] = UserWarning,
202
+ stacklevel: int = 2) -> None:
203
+ """Warn once per layout (:func:`warnings_once`): a warning given again with the same
204
+ ``key`` (default: the message) replaces the earlier one, so a layout round's later
205
+ numbers are the ones shown. Outside a layout, warn at once."""
206
+ pending = _PENDING.get()
207
+ if pending is None:
208
+ warnings.warn(message, category, stacklevel=stacklevel + 1)
209
+ return
210
+ pending[message if key is None else key] = (message, category)
211
+
212
+
213
+ @contextlib.contextmanager
214
+ def warnings_once() -> Generator[None]:
215
+ """Collect :func:`warn` calls and give each once at the end (nested: the outermost
216
+ gives them)."""
217
+ if _PENDING.get() is not None:
218
+ yield
219
+ return
220
+ pending: dict[Any, tuple[str, type[Warning]]] = {}
221
+ token = _PENDING.set(pending)
222
+ try:
223
+ yield
224
+ finally:
225
+ _PENDING.reset(token)
226
+ for message, category in pending.values():
227
+ warnings.warn(message, category, stacklevel=4)
228
+
229
+
230
+ def set_boxes(obj: Any, **boxes: float) -> None:
231
+ """The layout boxes `%` of ``obj``'s settings (and its children's) refers to."""
232
+ r = _CURRENT.get()
233
+ if r is not None:
234
+ r.boxes[id(obj)] = boxes
235
+
236
+
237
+ def auto_colors(series: Any) -> list[Any] | None:
238
+ """The plot's colors ``series`` takes in the render in progress (``None``: none)."""
239
+ r = _CURRENT.get()
240
+ return None if r is None else r.colors.get(id(series))