plot-io 0.12.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-x86_64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/mapped.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Color and size axes: the scales of values drawn as colors and as sizes.
|
|
2
|
+
|
|
3
|
+
Numbers given as colors (per point, per cell, per category) or as sizes go through an axis,
|
|
4
|
+
as positions do: its scale and limits take them to [0, 1], then a gradient (a
|
|
5
|
+
:class:`~plot_io.ColorMap`) or a range of sizes (a :class:`~plot_io.SizeMap`) gives the color
|
|
6
|
+
or the diameter. :class:`ColorAxis` and :class:`SizeAxis` are axes like ``plot.x_axis``
|
|
7
|
+
(scale, limits, ticks, number format, label); a colorbar and a size key are their guides.
|
|
8
|
+
|
|
9
|
+
* A series that maps values has axes of its own (``series.color_axis``,
|
|
10
|
+
``series.size_axis``), configured by its ``color_*`` and ``size_*`` settings
|
|
11
|
+
(``color_limits`` is the axis' ``limits``, ``color_scale`` its ``scale``, ``color_map``
|
|
12
|
+
its gradient).
|
|
13
|
+
* A shared axis (``figure.color_axis(…)``, ``figure.size_axis(…)``) is given to series as
|
|
14
|
+
``color_axis=`` or ``size_axis=``; it holds the gradient (or sizes), the limits and the
|
|
15
|
+
scale, and its automatic limits cover every series using it.
|
|
16
|
+
* The figure's ``share_color`` (``share_size``) trains the own color (size) axes of the
|
|
17
|
+
plots of a group together, as ``share_x`` does for x axes.
|
|
18
|
+
|
|
19
|
+
:func:`train` resolves every axis used in a layout (scale, then limits over its values)
|
|
20
|
+
into an :class:`~plot_io.axis.AxisState`, kept in the render (``Render.scales``), and
|
|
21
|
+
groups the series of each trained size axis into one size key (``Render.size_keys``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
from array import array
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ._methods import ColorAxisMixin, SizeAxisMixin
|
|
30
|
+
from . import kernels
|
|
31
|
+
from .axis import Axis, AxisState
|
|
32
|
+
from .color.maps import MAP_WORDS, ColorMap, check_color_map, chosen_map, kind_settings
|
|
33
|
+
from .scales import NUMERIC_SCALES
|
|
34
|
+
from .settings import (DECLINE, Setting, Settings, cascade, given_rule, number, one_of,
|
|
35
|
+
table)
|
|
36
|
+
from .spec import current
|
|
37
|
+
from .values import check_size_map as _check_size_map
|
|
38
|
+
from .validators import typed
|
|
39
|
+
|
|
40
|
+
__all__ = ["ColorAxis", "SizeAxis", "color_axis_of", "size_axis_of", "shared_axis", "own_axis",
|
|
41
|
+
"group_axes", "train", "trained", "SizeGroup", "size_keys", "size_group",
|
|
42
|
+
"colors_of", "unit_of", "check_color_axis", "check_size_axis"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _scale_setting(what: str) -> Setting:
|
|
46
|
+
return Setting(one_of(*NUMERIC_SCALES),
|
|
47
|
+
doc=f"Scale of the {what}: `linear`, `log` (base 10), `ln` (base e), "
|
|
48
|
+
"`log2`, `log10`, `sqrt`, `exp`, `symlog` or `logit`; default "
|
|
49
|
+
"`linear`.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _from_series(name: str, key: str | None = None) -> Callable[[Any], Any]:
|
|
53
|
+
"""Rule of an own axis' setting ``name``: the series' ``key`` (default ``color_<name>``,
|
|
54
|
+
``size_<name>``), when it has one."""
|
|
55
|
+
@given_rule
|
|
56
|
+
def rule(axis: "_MappedAxis") -> Any:
|
|
57
|
+
s = axis.series
|
|
58
|
+
nonlocal key
|
|
59
|
+
if key is None:
|
|
60
|
+
key = f"{axis.PREFIX}_{name}"
|
|
61
|
+
if s is None or key not in s.SETTINGS:
|
|
62
|
+
return DECLINE
|
|
63
|
+
v = s._raw(key)
|
|
64
|
+
return DECLINE if v is None else v
|
|
65
|
+
return rule
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _MappedAxis(Axis):
|
|
69
|
+
"""An axis of mapped values: a series' own (``series``) or shared (owned by a figure)."""
|
|
70
|
+
|
|
71
|
+
ROLE = "c"
|
|
72
|
+
PREFIX = "color"
|
|
73
|
+
|
|
74
|
+
def __init__(self, figure: Any = None, **settings: Any) -> None:
|
|
75
|
+
self.series: Any = None
|
|
76
|
+
super().__init__(figure, self.ROLE)
|
|
77
|
+
if settings:
|
|
78
|
+
self.set(**settings)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def own(cls, series: Any) -> Any:
|
|
82
|
+
"""The own axis of ``series`` (its parent: the series' settings flow into it)."""
|
|
83
|
+
axis = cls.__new__(cls)
|
|
84
|
+
axis.series = series
|
|
85
|
+
Axis.__init__(axis, series, cls.ROLE)
|
|
86
|
+
return axis
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
owner = "shared" if self.series is None else repr(self.series)
|
|
90
|
+
return f"{type(self).__name__}({owner})"
|
|
91
|
+
|
|
92
|
+
def _box(self, kind: str, name: str) -> float:
|
|
93
|
+
if kind in ("across", "along"): # of the guide drawing it (a colorbar)
|
|
94
|
+
r = current()
|
|
95
|
+
boxes = r.boxes.get(id(self)) if r is not None else None
|
|
96
|
+
vertical = bool(boxes and boxes.get("vertical"))
|
|
97
|
+
kind = "axes_width" if (kind == "across") == vertical else "axes_height"
|
|
98
|
+
return Settings._box(self, kind, name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ColorAxis(ColorAxisMixin, _MappedAxis):
|
|
102
|
+
"""The axis of values drawn as colors: scale, limits, ticks and number format, and the
|
|
103
|
+
gradient (``color_map``). ``figure.color_axis(…)`` makes a shared one, given to series
|
|
104
|
+
as ``color_axis=``; a colorbar is its guide.
|
|
105
|
+
|
|
106
|
+
A series that maps values has its own color axis (``series.color_axis``): its ``scale``
|
|
107
|
+
is the series' ``color_scale``, its ``limits`` the series' ``color_limits``, its gradient
|
|
108
|
+
the series' ``color_map``; a ``scale`` or ``limits`` set on the axis itself wins. A
|
|
109
|
+
shared axis covers every series using it, and those take no ``color_map``,
|
|
110
|
+
``color_limits`` or ``color_scale`` of their own (an error); one made alone
|
|
111
|
+
(``plt.ColorAxis()``) joins the figure of its first series. The members of a ``group=``
|
|
112
|
+
call share one. The figure's ``share_color`` makes the own axes of each group of plots
|
|
113
|
+
one (one ``color_limits`` or scale for the group; two different ones are an error),
|
|
114
|
+
each series keeping its gradient. Training: before the layout, limits come from the
|
|
115
|
+
values as for x and y axes (``limits``, quantiles, ``include_zero``), without padding
|
|
116
|
+
unless ``padding`` is set. Values past the limits take the gradient's ``under`` and
|
|
117
|
+
``over`` colors, missing values and values outside the scale's domain its ``bad`` color
|
|
118
|
+
(the latter with a warning).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
SETTINGS = table(**{**Axis.SETTINGS, "scale": _scale_setting("mapped values")},
|
|
122
|
+
color_map=Setting(check_color_map,
|
|
123
|
+
doc="The gradient: a `ColorMap`, a map name or "
|
|
124
|
+
"colors (alias `cmap`), `\"auto\"` "
|
|
125
|
+
"(`sequential_map`, or `diverging_map` about a "
|
|
126
|
+
"`center`) or a kind (that kind's map)."),
|
|
127
|
+
center=Setting(number(),
|
|
128
|
+
doc="The value at the map's middle; the limits "
|
|
129
|
+
"symmetric about it unless given; the colorbar "
|
|
130
|
+
"marks it."),
|
|
131
|
+
**kind_settings())
|
|
132
|
+
RULES = {"limits": _from_series("limits"), "scale": _from_series("scale"),
|
|
133
|
+
"color_map": _from_series("map"), "center": _from_series("center", "center")}
|
|
134
|
+
|
|
135
|
+
def color_map(self) -> ColorMap:
|
|
136
|
+
"""The gradient values are drawn with (in the axis' color variant): with a
|
|
137
|
+
``center``, its middle at the centre (a part of it when the limits are given
|
|
138
|
+
unevenly about the centre)."""
|
|
139
|
+
center = self.resolve("center")
|
|
140
|
+
v = self.resolve("color_map")
|
|
141
|
+
if not isinstance(v, ColorMap) and not (isinstance(v, str) and v in MAP_WORDS):
|
|
142
|
+
v = "auto" # a series' colors by level, or its one color
|
|
143
|
+
cm = chosen_map(self, v, "sequential" if center is None else "diverging")
|
|
144
|
+
if center is None:
|
|
145
|
+
return cm
|
|
146
|
+
r = current()
|
|
147
|
+
st = r.scales.get(id(self)) if r is not None else None
|
|
148
|
+
if st is None or st.scale is None:
|
|
149
|
+
return cm
|
|
150
|
+
fc = st.scale.f(center)
|
|
151
|
+
d = max(fc - st.flo, st.fhi - fc)
|
|
152
|
+
if not (math.isfinite(fc) and d > 0):
|
|
153
|
+
return cm
|
|
154
|
+
a, b = (st.flo - fc + d) / (2 * d), (st.fhi - fc + d) / (2 * d)
|
|
155
|
+
if a <= 1e-12 and b >= 1 - 1e-12:
|
|
156
|
+
return cm
|
|
157
|
+
return cm.truncated(max(a, 0.0), min(b, 1.0))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SizeAxis(SizeAxisMixin, _MappedAxis):
|
|
163
|
+
"""The axis of values drawn as marker sizes: scale, limits, the values and number
|
|
164
|
+
format of its size key (``ticks``, ``tick_format``), its title (``label``), and the
|
|
165
|
+
sizes (``size_map``). ``figure.size_axis(…)`` makes a shared one, given to series as
|
|
166
|
+
``size_axis=``; a size key is its guide.
|
|
167
|
+
|
|
168
|
+
As :class:`ColorAxis`, for sizes: the series' own (``series.size_axis``: ``size_scale``,
|
|
169
|
+
``size_limits``, ``size_map``), shared by ``figure.size_axis()``, or made one per group by
|
|
170
|
+
the figure's ``share_size``, which also gives the group one size key beside its plots.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
ROLE = "s"
|
|
174
|
+
PREFIX = "size"
|
|
175
|
+
|
|
176
|
+
SETTINGS = table(**{**Axis.SETTINGS, "scale": _scale_setting("mapped values")},
|
|
177
|
+
size_map=Setting(_check_size_map,
|
|
178
|
+
doc="The sizes: a `SizeMap` or a pair of diameters; "
|
|
179
|
+
"default `SizeMap()`."))
|
|
180
|
+
RULES = {"limits": _from_series("limits"), "scale": _from_series("scale"),
|
|
181
|
+
"size_map": _from_series("map")}
|
|
182
|
+
|
|
183
|
+
def _levels(self, name: str) -> tuple[tuple[str, ...], ...]:
|
|
184
|
+
return (tuple(c for level in Settings._levels(self, name) for c in level),)
|
|
185
|
+
|
|
186
|
+
def size_map(self) -> Any:
|
|
187
|
+
"""The sizes values are drawn with."""
|
|
188
|
+
from .sizemaps import DEFAULT
|
|
189
|
+
m = self.resolve("size_map")
|
|
190
|
+
return DEFAULT if m is None else m
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@typed("ColorAxis")
|
|
194
|
+
def check_color_axis(v: Any) -> Any:
|
|
195
|
+
if isinstance(v, ColorAxis):
|
|
196
|
+
return v
|
|
197
|
+
raise ValueError(f"expected a ColorAxis (figure.color_axis(…)), got {v!r}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@typed("SizeAxis")
|
|
201
|
+
def check_size_axis(v: Any) -> Any:
|
|
202
|
+
if isinstance(v, SizeAxis):
|
|
203
|
+
return v
|
|
204
|
+
raise ValueError(f"expected a SizeAxis (figure.size_axis(…)), got {v!r}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# The axis of a series
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def own_axis(series: Any, cls: type) -> Any:
|
|
212
|
+
"""The own axis of class ``cls`` of ``series``, made on first use."""
|
|
213
|
+
key = f"_{cls.PREFIX}_axis" # type: ignore[attr-defined]
|
|
214
|
+
axis = series.__dict__.get(key)
|
|
215
|
+
if axis is None:
|
|
216
|
+
axis = cls.own(series) # type: ignore[attr-defined]
|
|
217
|
+
series.__dict__[key] = axis
|
|
218
|
+
return axis
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def shared_axis(series: Any, what: str) -> Any:
|
|
222
|
+
"""The shared axis given as ``color_axis=`` (``size_axis=``), or ``None``."""
|
|
223
|
+
name = f"{what}_axis"
|
|
224
|
+
if name not in series.SETTINGS:
|
|
225
|
+
return None
|
|
226
|
+
axis = series.resolve(name)
|
|
227
|
+
if axis is not None:
|
|
228
|
+
given = [n for n in (f"{what}_map", f"{what}_limits", f"{what}_scale")
|
|
229
|
+
if series.is_set(n)]
|
|
230
|
+
if given:
|
|
231
|
+
raise ValueError(f"{series.kind}: {', '.join(given)} belong to the shared "
|
|
232
|
+
f"{what} axis given as {name}= (set them there)")
|
|
233
|
+
return axis
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def color_axis_of(series: Any) -> ColorAxis:
|
|
237
|
+
"""The color axis numbers in the series' colors go through: the shared one given as
|
|
238
|
+
``color_axis=``, else the series' own."""
|
|
239
|
+
axis = shared_axis(series, "color")
|
|
240
|
+
return axis if axis is not None else own_axis(series, ColorAxis)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def size_axis_of(series: Any, name: str = "size") -> SizeAxis | None:
|
|
244
|
+
"""The size axis of a size setting holding values to map, or ``None`` (sizes in px):
|
|
245
|
+
a density always maps; numbers per point map with a ``size_map`` or a ``size_axis``."""
|
|
246
|
+
from .values import is_computed
|
|
247
|
+
if name not in series.SETTINGS or "size_map" not in series.SETTINGS:
|
|
248
|
+
return None
|
|
249
|
+
v = series.resolve(name)
|
|
250
|
+
computed = is_computed(v)
|
|
251
|
+
if not computed and isinstance(v, (int, float)):
|
|
252
|
+
return None
|
|
253
|
+
axis = shared_axis(series, "size")
|
|
254
|
+
if axis is not None:
|
|
255
|
+
return axis
|
|
256
|
+
if computed or series.resolve("size_map") is not None:
|
|
257
|
+
return own_axis(series, SizeAxis)
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _maps_colors(v: Any) -> bool:
|
|
262
|
+
from .values import Values, is_computed, paint
|
|
263
|
+
if v is None:
|
|
264
|
+
return False
|
|
265
|
+
try:
|
|
266
|
+
out = paint(v)
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
return False
|
|
269
|
+
return isinstance(out, Values) or is_computed(out)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
#: Keywords of a call that configure its color or size axis, and the axis' names for them.
|
|
273
|
+
_AXIS_KEYS = {"color": {"color_map": "color_map", "color_limits": "limits",
|
|
274
|
+
"color_scale": "scale"},
|
|
275
|
+
"size": {"size_map": "size_map", "size_limits": "limits", "size_scale": "scale"}}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def group_axes(cls: type, kw: dict[str, Any]) -> None:
|
|
279
|
+
"""The keywords of a ``group=`` call, given shared color and size axes where the call
|
|
280
|
+
maps values: its members are one call, on one axis each (the ``color_*``/``size_*``
|
|
281
|
+
keywords go to the axes)."""
|
|
282
|
+
settings = getattr(cls, "SETTINGS", {})
|
|
283
|
+
from .layers.paint import COLOR_SETTINGS
|
|
284
|
+
from .values import is_computed
|
|
285
|
+
for what, axis_cls in (("color", ColorAxis), ("size", SizeAxis)):
|
|
286
|
+
name = f"{what}_axis"
|
|
287
|
+
if name not in settings or kw.get(name) is not None:
|
|
288
|
+
continue
|
|
289
|
+
keys = _AXIS_KEYS[what]
|
|
290
|
+
if what == "color":
|
|
291
|
+
maps = any(_maps_colors(kw.get(k)) for k in COLOR_SETTINGS)
|
|
292
|
+
else:
|
|
293
|
+
v = kw.get("size")
|
|
294
|
+
density = is_computed(v) or (isinstance(v, str) and v.strip().lower() == "density")
|
|
295
|
+
maps = density or (kw.get("size_map") is not None and v is not None
|
|
296
|
+
and not isinstance(v, (int, float, str)))
|
|
297
|
+
if maps:
|
|
298
|
+
kw[name] = axis_cls(**{to: kw.pop(k) for k, to in keys.items()
|
|
299
|
+
if kw.get(k) is not None})
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Training
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
class _Values:
|
|
307
|
+
"""Values on a mapped axis, read by its training as a call's values are by an x axis."""
|
|
308
|
+
|
|
309
|
+
__slots__ = ("data",)
|
|
310
|
+
SETTINGS: dict[str, Any] = {}
|
|
311
|
+
|
|
312
|
+
def __init__(self, data: Any) -> None:
|
|
313
|
+
self.data = data
|
|
314
|
+
|
|
315
|
+
def has_axis(self, role: str) -> bool:
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def categories(self, role: str) -> None:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
322
|
+
return kernels.extent(self.data, scale.function)
|
|
323
|
+
|
|
324
|
+
def values(self, role: str) -> Any:
|
|
325
|
+
return array("d", [v for v in kernels.doubles(self.data) if math.isfinite(v)])
|
|
326
|
+
|
|
327
|
+
def sticky(self, role: str) -> list[float]:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
def zero_default(self, role: str) -> bool:
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
def zero_padding(self, role: str) -> bool:
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
def inverts(self, role: str) -> bool:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _explicit(axis: Any, name: str) -> bool:
|
|
341
|
+
"""Whether an axis' setting is given (on it, or by its series' settings)."""
|
|
342
|
+
return cascade(axis, name)[2] in ("own", "rule")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def train(fig: Any, areas: list[Any]) -> None:
|
|
346
|
+
"""Train every color and size axis of the figure's layout ``areas``: the axes of the
|
|
347
|
+
calls' mapped values, those of colorbars and size legends, the own color (size) axes of
|
|
348
|
+
plots sharing them (``share_color``, ``share_size``) together; into the render's
|
|
349
|
+
``scales``. Then the size keys of the trained size axes (``size_keys``)."""
|
|
350
|
+
from .colorbar import Colorbar
|
|
351
|
+
from .layers.pipeline import plot_calls
|
|
352
|
+
from .legend import SizeLegend
|
|
353
|
+
from .plot import Plot
|
|
354
|
+
from . import sharing
|
|
355
|
+
r = current()
|
|
356
|
+
assert r is not None
|
|
357
|
+
r.scales.clear()
|
|
358
|
+
axes: dict[int, Any] = {}
|
|
359
|
+
values: dict[int, list[Any]] = {}
|
|
360
|
+
plot_of: dict[int, Any] = {} # own axes: the plot of their series
|
|
361
|
+
|
|
362
|
+
def use(axis: Any, v: Any = None, plot: Any = None) -> None:
|
|
363
|
+
axes.setdefault(id(axis), axis)
|
|
364
|
+
vs = values.setdefault(id(axis), [])
|
|
365
|
+
if callable(v): # values counted for the placed axes
|
|
366
|
+
v = v()
|
|
367
|
+
if v is not None:
|
|
368
|
+
vs.append(v)
|
|
369
|
+
if plot is not None and axis.series is not None:
|
|
370
|
+
plot_of[id(axis)] = plot
|
|
371
|
+
|
|
372
|
+
for a in areas:
|
|
373
|
+
if isinstance(a.obj, Plot):
|
|
374
|
+
for p in [a.obj, *a.obj.twins]:
|
|
375
|
+
for c in plot_calls(p):
|
|
376
|
+
for axis, v in c.color_sources:
|
|
377
|
+
use(axis, v, a.obj)
|
|
378
|
+
for axis, v in c.size_sources:
|
|
379
|
+
use(axis, v, a.obj)
|
|
380
|
+
for cb in a.obj.colorbars:
|
|
381
|
+
use(cb.axis)
|
|
382
|
+
elif isinstance(a.obj, Colorbar):
|
|
383
|
+
use(a.obj.axis)
|
|
384
|
+
elif isinstance(a.obj, SizeLegend):
|
|
385
|
+
use(a.obj.size_axis)
|
|
386
|
+
for cb in fig.colorbars:
|
|
387
|
+
use(cb.axis)
|
|
388
|
+
root = {k: k for k in axes}
|
|
389
|
+
|
|
390
|
+
def find(k: int) -> int:
|
|
391
|
+
while root[k] != k:
|
|
392
|
+
k = root[k]
|
|
393
|
+
return k
|
|
394
|
+
r.levels.clear()
|
|
395
|
+
r.levels_group.clear()
|
|
396
|
+
for role, cls in (("color", ColorAxis), ("size", SizeAxis)):
|
|
397
|
+
for members, _ in sharing.groups(fig, areas, role):
|
|
398
|
+
in_group = {id(m.obj) for m in members}
|
|
399
|
+
if role == "color":
|
|
400
|
+
r.levels_group.update((k, id(members[0].obj)) for k in in_group)
|
|
401
|
+
ids = [k for k, p in plot_of.items()
|
|
402
|
+
if id(p) in in_group and isinstance(axes[k], cls)]
|
|
403
|
+
for k in ids[1:]:
|
|
404
|
+
ra, rb = find(ids[0]), find(k)
|
|
405
|
+
if ra != rb:
|
|
406
|
+
root[rb] = ra
|
|
407
|
+
grouped: dict[int, list[int]] = {}
|
|
408
|
+
for k in axes:
|
|
409
|
+
grouped.setdefault(find(k), []).append(k)
|
|
410
|
+
for ids in grouped.values():
|
|
411
|
+
members = [axes[k] for k in ids]
|
|
412
|
+
lead = members[0]
|
|
413
|
+
if len(members) > 1:
|
|
414
|
+
role = "size" if isinstance(lead, SizeAxis) else "color"
|
|
415
|
+
lead = members[sharing.leader(members, role, _explicit)]
|
|
416
|
+
st = AxisState(lead)
|
|
417
|
+
st.resolve([_Values(v) for k in ids for v in values[k]], False)
|
|
418
|
+
for k in ids:
|
|
419
|
+
r.scales[k] = st
|
|
420
|
+
r.size_keys = size_keys(areas)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class SizeGroup:
|
|
424
|
+
"""The series of one trained size axis and one size map, across a layout: they have
|
|
425
|
+
one size key. ``axis``: the first one's size axis; ``users``: every series; ``listed``:
|
|
426
|
+
those whose ``size_key`` is ``"legend"``; ``plot``: the plot whose legend shows the
|
|
427
|
+
key (``None``: a size legend area shows it, or no series is listed)."""
|
|
428
|
+
|
|
429
|
+
__slots__ = ("axis", "users", "listed", "plot")
|
|
430
|
+
|
|
431
|
+
def __init__(self, axis: Any) -> None:
|
|
432
|
+
self.axis = axis
|
|
433
|
+
self.users: list[Any] = []
|
|
434
|
+
self.listed: list[Any] = []
|
|
435
|
+
self.plot: Any = None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _size_key(axis: Any) -> tuple[int, str] | None:
|
|
439
|
+
r = current()
|
|
440
|
+
st = r.scales.get(id(axis)) if r is not None else None
|
|
441
|
+
return None if st is None else (id(st), repr(axis.size_map()))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def size_keys(areas: list[Any]) -> dict[Any, SizeGroup]:
|
|
445
|
+
"""The size keys of a layout, by trained axis and size map. Series of a shared size axis
|
|
446
|
+
(one axis, or own axes trained as one by ``share_size``) in several plots have one key,
|
|
447
|
+
in the legend of the rightmost of those plots (the topmost of those); a size legend
|
|
448
|
+
area showing the axis takes it out of the plot legends."""
|
|
449
|
+
from .legend import SizeLegend
|
|
450
|
+
from .plot import Plot
|
|
451
|
+
out: dict[Any, SizeGroup] = {}
|
|
452
|
+
place: dict[Any, tuple[int, int]] = {}
|
|
453
|
+
for a in areas:
|
|
454
|
+
if not isinstance(a.obj, Plot):
|
|
455
|
+
continue
|
|
456
|
+
for p in [a.obj, *a.obj.twins]:
|
|
457
|
+
for s in p.series:
|
|
458
|
+
axis = size_axis_of(s)
|
|
459
|
+
key = None if axis is None else _size_key(axis)
|
|
460
|
+
if key is None:
|
|
461
|
+
continue
|
|
462
|
+
g = out.get(key)
|
|
463
|
+
if g is None:
|
|
464
|
+
g = out[key] = SizeGroup(axis)
|
|
465
|
+
g.users.append(s)
|
|
466
|
+
if "size_key" not in s.SETTINGS or s.resolve("size_key") != "legend":
|
|
467
|
+
continue
|
|
468
|
+
g.listed.append(s)
|
|
469
|
+
rank = (-a.c1, a.r0)
|
|
470
|
+
if g.plot is None or rank < place[key]:
|
|
471
|
+
g.plot, place[key] = p, rank
|
|
472
|
+
for a in areas:
|
|
473
|
+
if isinstance(a.obj, SizeLegend):
|
|
474
|
+
g = out.get(_size_key(a.obj.size_axis))
|
|
475
|
+
if g is not None:
|
|
476
|
+
g.plot = None
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def trained(axis: Any) -> AxisState:
|
|
481
|
+
"""The trained state of a color or size axis in the render in progress."""
|
|
482
|
+
r = current()
|
|
483
|
+
st = r.scales.get(id(axis)) if r is not None else None
|
|
484
|
+
if st is None:
|
|
485
|
+
raise ValueError(f"{axis!r} maps no values of the figure being drawn (values of "
|
|
486
|
+
"this setting are not mapped by its series)")
|
|
487
|
+
return st
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def size_group(axis: Any) -> SizeGroup | None:
|
|
491
|
+
"""The size key group of a trained size axis in the render in progress."""
|
|
492
|
+
r = current()
|
|
493
|
+
key = _size_key(axis)
|
|
494
|
+
return None if key is None or r is None else r.size_keys.get(key)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def unit_of(axis: Any) -> Callable[[float], float]:
|
|
498
|
+
"""``v -> t``: a value's position on the trained axis (0 and 1 at the limits)."""
|
|
499
|
+
st = trained(axis)
|
|
500
|
+
assert st.scale is not None
|
|
501
|
+
f, flo = st.scale.f, st.flo
|
|
502
|
+
span = (st.fhi - st.flo) or 1.0
|
|
503
|
+
return lambda v: (f(v) - flo) / span
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def colors_of(axis: Any, values: Any) -> Any:
|
|
507
|
+
"""Colors of many values through a trained color axis (an ``(n, 4)`` buffer); values
|
|
508
|
+
outside the scale's domain (0 on a log scale) take the map's ``bad`` color, as missing
|
|
509
|
+
ones, with a warning naming their count."""
|
|
510
|
+
st = trained(axis)
|
|
511
|
+
assert st.scale is not None
|
|
512
|
+
if st.scale.function is not None:
|
|
513
|
+
outside = kernels.extent(values)[0] - kernels.extent(values, st.scale.function)[0]
|
|
514
|
+
if outside:
|
|
515
|
+
import warnings
|
|
516
|
+
warnings.warn(f"{outside} color value{'s' if outside > 1 else ''} outside the "
|
|
517
|
+
f"{st.scale.name} scale's domain: drawn in the color map's bad "
|
|
518
|
+
"color", stacklevel=2)
|
|
519
|
+
span = st.fhi - st.flo
|
|
520
|
+
k = 1.0 / span if span else 0.0
|
|
521
|
+
ts = kernels.scaled(values, k, -st.flo * k, st.scale.function, **st.scale.options)
|
|
522
|
+
return axis.color_map().colors_at(ts)
|
plot_io/markers.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Marker shapes: unit paths centred on the origin (y down) and their areas.
|
|
2
|
+
|
|
3
|
+
vec_io draws markers from paths; the names live here. The paths of ``square``,
|
|
4
|
+
``diamond``, ``triangle_up``, ``triangle_down``, ``plus`` and ``cross`` are those vec_io
|
|
5
|
+
0.3 bundled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from vec_io import Path
|
|
13
|
+
from vec_io.geometry import ellipse_path, parse_path_data
|
|
14
|
+
|
|
15
|
+
from .settings import MARKER_ALIASES
|
|
16
|
+
|
|
17
|
+
__all__ = ["MARKERS", "add_marker", "resolve", "marker_area"]
|
|
18
|
+
|
|
19
|
+
_S3 = math.sqrt(3.0)
|
|
20
|
+
_T = f"{_S3 / 3!r}"
|
|
21
|
+
_H = f"{_S3 / 6!r}"
|
|
22
|
+
|
|
23
|
+
_DATA = {
|
|
24
|
+
"square": "M-0.5 -0.5 H0.5 V0.5 H-0.5 Z",
|
|
25
|
+
"diamond": "M0 -0.5 L0.5 0 L0 0.5 L-0.5 0 Z",
|
|
26
|
+
"triangle_up": f"M0 -{_T} L0.5 {_H} L-0.5 {_H} Z",
|
|
27
|
+
"triangle_down": f"M0 {_T} L0.5 -{_H} L-0.5 -{_H} Z",
|
|
28
|
+
"triangle_left": f"M-{_T} 0 L{_H} -0.5 L{_H} 0.5 Z",
|
|
29
|
+
"triangle_right": f"M{_T} 0 L-{_H} 0.5 L-{_H} -0.5 Z",
|
|
30
|
+
"plus": "M-0.5 -0.15 H-0.15 V-0.5 H0.15 V-0.15 H0.5 V0.15 H0.15 V0.5 H-0.15 V0.15 H-0.5 Z",
|
|
31
|
+
"cross": "M-0.5 -0.29 L-0.29 -0.5 L0 -0.21 L0.29 -0.5 L0.5 -0.29 L0.21 0 L0.5 0.29 "
|
|
32
|
+
"L0.29 0.5 L0 0.21 L-0.29 0.5 L-0.5 0.29 L-0.21 0 Z",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: Exact areas of the unit shapes (fractions of the unit box).
|
|
36
|
+
_AREAS = {
|
|
37
|
+
"circle": math.pi / 4,
|
|
38
|
+
"square": 1.0,
|
|
39
|
+
"diamond": 0.5,
|
|
40
|
+
"triangle_up": _S3 / 4,
|
|
41
|
+
"triangle_down": _S3 / 4,
|
|
42
|
+
"triangle_left": _S3 / 4,
|
|
43
|
+
"triangle_right": _S3 / 4,
|
|
44
|
+
"plus": 0.51,
|
|
45
|
+
"cross": 0.5754,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#: Registered shapes: name → (path, area).
|
|
49
|
+
MARKERS: dict[str, tuple[Path, float]] = {"circle": (ellipse_path(0, 0, 0.5, 0.5), math.pi / 4)}
|
|
50
|
+
MARKERS.update((name, (parse_path_data(d), _AREAS[name])) for name, d in _DATA.items())
|
|
51
|
+
|
|
52
|
+
_ALIASES = MARKER_ALIASES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_PATH_DATA = re.compile(r"^\s*[Mm]\s*[-+.\d]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_path_data(value: Any) -> bool:
|
|
59
|
+
return isinstance(value, str) and _PATH_DATA.match(value) is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _name(name: str) -> str:
|
|
63
|
+
key = name.strip().lower().replace("-", "_")
|
|
64
|
+
return _ALIASES.get(key, key)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_marker(name: str, path: Any) -> None:
|
|
68
|
+
"""Add a marker shape: a ``vec_io.Path`` or SVG path data in a unit box centred on the
|
|
69
|
+
origin. Its area is measured from the outline."""
|
|
70
|
+
p = _as_path(path)
|
|
71
|
+
MARKERS[_name(name)] = (p, float(p.area()))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _as_path(path: Any) -> Path:
|
|
75
|
+
if isinstance(path, Path):
|
|
76
|
+
return path
|
|
77
|
+
if _is_path_data(path):
|
|
78
|
+
return parse_path_data(path)
|
|
79
|
+
raise ValueError(f"expected a vec_io Path or path data, got {path!r}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve(marker: Any) -> tuple[Path, float]:
|
|
83
|
+
"""``(path, area)`` of a marker name, ``Path`` or path data."""
|
|
84
|
+
if isinstance(marker, str) and not _is_path_data(marker):
|
|
85
|
+
try:
|
|
86
|
+
return MARKERS[_name(marker)]
|
|
87
|
+
except KeyError:
|
|
88
|
+
names = ", ".join(sorted(MARKERS))
|
|
89
|
+
raise ValueError(f"unknown marker {marker!r} (use {names}, a Path or path data)") \
|
|
90
|
+
from None
|
|
91
|
+
p = _as_path(marker)
|
|
92
|
+
return p, float(p.area())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_marker(marker: Any) -> Any:
|
|
96
|
+
resolve(marker)
|
|
97
|
+
if isinstance(marker, str) and not _is_path_data(marker):
|
|
98
|
+
return _name(marker)
|
|
99
|
+
return marker
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def marker_area(marker: Any) -> float:
|
|
103
|
+
"""Area of a marker's unit shape (fraction of its unit box)."""
|
|
104
|
+
return resolve(marker)[1]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def area_factor(marker: Any) -> float:
|
|
108
|
+
"""Size factor that gives ``marker`` the area of a circle of the same size."""
|
|
109
|
+
a = marker_area(marker)
|
|
110
|
+
return math.sqrt((math.pi / 4) / a) if a > 0 else 1.0
|