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/levels.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Level axes: a column of categories as an aesthetic (``color``, ``marker``,
|
|
2
|
+
``line_style``, ``size``).
|
|
3
|
+
|
|
4
|
+
An aesthetic holds **levels** when it is a pandas or polars categorical, a ``data=``
|
|
5
|
+
column of text or booleans, or a list or array holding a value the setting does not take
|
|
6
|
+
(a list of colours, markers… stays one value per item). A :class:`LevelAxis` holds the
|
|
7
|
+
levels of one column (``level_order`` first, then the categorical order or first
|
|
8
|
+
appearance; a missing value is the level ``"nan"``, or its rows are left out with
|
|
9
|
+
``level_missing="drop"``) and maps each to a value of its aesthetics: colours from the
|
|
10
|
+
plot's ``qualitative_map`` (its next colours in call order, as ``group=`` members take
|
|
11
|
+
them), or from the series' own ``color_map`` (a map's colours for the levels, a dict by
|
|
12
|
+
level), markers from
|
|
13
|
+
``markers``, line styles from ``line_styles``, sizes from ``size_map`` (even by area).
|
|
14
|
+
Calls of a plot mapping the same column, and plots grouped by ``share_color``, give a
|
|
15
|
+
level one colour.
|
|
16
|
+
|
|
17
|
+
A type paints by levels (``Series.LEVELS``) as ``"items"`` (scatter: each point by its
|
|
18
|
+
levels, one call) or ``"split"`` (one member call per level combination, drawn,
|
|
19
|
+
positioned and coloured as the members of ``group=``: their points in data order). The legend has a section per
|
|
20
|
+
axis, titled by its column (or ``name``), with an entry per level; with one axis the
|
|
21
|
+
section's title is the legend's.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import array
|
|
25
|
+
import functools
|
|
26
|
+
import math
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from . import kernels
|
|
30
|
+
from .columns import _length, _missing, _order
|
|
31
|
+
from .validators import typed
|
|
32
|
+
|
|
33
|
+
__all__ = ["AESTHETICS", "adopt", "LevelAxis", "Section", "detect", "make", "calls_of", "assign",
|
|
34
|
+
"level_color_count", "facet_orders", "level_order"]
|
|
35
|
+
|
|
36
|
+
AESTHETICS = ("color", "marker", "line_style", "size")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# -- settings ------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
@typed("Column | Mapping[Any, Any]")
|
|
42
|
+
def level_order(v: Any) -> Any:
|
|
43
|
+
"""``level_order``, ``markers``, ``line_styles``: a list, or a dict of values by
|
|
44
|
+
column (by level for markers and line styles); the values are checked where used."""
|
|
45
|
+
if isinstance(v, dict):
|
|
46
|
+
return dict(v)
|
|
47
|
+
if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__") or not len(v := list(v)):
|
|
48
|
+
raise ValueError(f"expected a list or a dict, got {v!r}")
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def adopt(cls: Any, kind: str) -> None:
|
|
53
|
+
"""Make ``cls`` draw levels as ``kind`` (``"items"``, ``"split"``), with the settings
|
|
54
|
+
of its level axes."""
|
|
55
|
+
from .layers.base import common
|
|
56
|
+
from .settings import table
|
|
57
|
+
names = ["level_order"] + [f"{a}s" for a in ("marker", "line_style") if a in cls.SETTINGS]
|
|
58
|
+
cls.LEVELS = kind
|
|
59
|
+
cls.SETTINGS = {**cls.SETTINGS, **{k: v for k, v in table(**common(*names)).items()
|
|
60
|
+
if k not in cls.SETTINGS}}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# -- detection -----------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def _items(v: Any) -> list[Any] | None:
|
|
66
|
+
if isinstance(v, (str, bytes, dict, tuple, int, float)) or v is None:
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(v, (array.array, memoryview)) and getattr(v, "typecode", "") != "u":
|
|
69
|
+
return None # numbers
|
|
70
|
+
if hasattr(v, "to_numpy") and not hasattr(v, "__array_interface__"):
|
|
71
|
+
v = v.to_numpy()
|
|
72
|
+
kind = getattr(getattr(v, "dtype", None), "kind", None)
|
|
73
|
+
if kind is not None and kind not in "OUSb":
|
|
74
|
+
return None # numbers
|
|
75
|
+
if hasattr(v, "tolist") and not isinstance(v, list):
|
|
76
|
+
v = v.tolist()
|
|
77
|
+
return v if isinstance(v, list) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_levels(cls: type, name: str, v: Any, titled: bool, order: Any) -> bool:
|
|
81
|
+
if order is not None:
|
|
82
|
+
return True
|
|
83
|
+
items = _items(v)
|
|
84
|
+
if not items:
|
|
85
|
+
return False
|
|
86
|
+
check = cls.SETTINGS[name].check # type: ignore[attr-defined]
|
|
87
|
+
first = next((x for x in items if x is not None), None)
|
|
88
|
+
if not isinstance(first, (str, bool)):
|
|
89
|
+
return False # numbers, colour tuples: per item
|
|
90
|
+
try:
|
|
91
|
+
unique = set(items)
|
|
92
|
+
except TypeError: # colour rows among the items
|
|
93
|
+
unique = {x for x in items if isinstance(x, (str, bool))}
|
|
94
|
+
for x in (x for x in unique if isinstance(x, (str, bool))):
|
|
95
|
+
if isinstance(x, bool) or titled:
|
|
96
|
+
return True
|
|
97
|
+
try:
|
|
98
|
+
check(x)
|
|
99
|
+
except (ValueError, TypeError):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def detect(cls: type, kw: dict[str, Any], titles: dict[str, Any],
|
|
105
|
+
orders: dict[str, Any]) -> list[tuple[str, Any, Any, Any]]:
|
|
106
|
+
"""The aesthetics of a call holding levels, taken out of ``kw``: ``(name, values,
|
|
107
|
+
column title, categorical order)``. Types that map none refuse them."""
|
|
108
|
+
settings = getattr(cls, "SETTINGS", {})
|
|
109
|
+
kind = getattr(cls, "LEVELS", None)
|
|
110
|
+
found: list[tuple[str, Any, Any, Any]] = []
|
|
111
|
+
if kind is None:
|
|
112
|
+
return found
|
|
113
|
+
for name in (*AESTHETICS, "opacity"):
|
|
114
|
+
v = kw.get(name)
|
|
115
|
+
if v is None or name not in settings:
|
|
116
|
+
continue
|
|
117
|
+
order = orders.get(name)
|
|
118
|
+
if order is None and not isinstance(v, (list, tuple, str)):
|
|
119
|
+
order = _order(v) # a categorical given directly
|
|
120
|
+
if not _is_levels(cls, name, v, name in titles, order):
|
|
121
|
+
continue
|
|
122
|
+
if name == "opacity":
|
|
123
|
+
raise ValueError(f"{cls.__name__}: opacity takes numbers, not levels (use color, "
|
|
124
|
+
"marker, line_style or size)")
|
|
125
|
+
if kind not in ("items", "split"):
|
|
126
|
+
raise ValueError(f"{cls.__name__}: {name} takes no levels; use plt.facet")
|
|
127
|
+
found.append((name, kw.pop(name), titles.get(name), order))
|
|
128
|
+
return found
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# -- axes ----------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
class LevelAxis:
|
|
134
|
+
"""The levels of one column and the aesthetics mapped from it.
|
|
135
|
+
|
|
136
|
+
``key``: the column title (``"#color"`` for an untitled list); ``levels`` in order;
|
|
137
|
+
``codes``: each row's level index (``-1``: left out); ``present``: the levels with
|
|
138
|
+
rows, in order."""
|
|
139
|
+
|
|
140
|
+
__slots__ = ("key", "title", "aesthetics", "levels", "codes", "present")
|
|
141
|
+
|
|
142
|
+
def __init__(self, name: str, values: Any, title: Any, order: Any, given: Any,
|
|
143
|
+
missing: str) -> None:
|
|
144
|
+
self.key = title if title is not None else "#" + name
|
|
145
|
+
self.title = title
|
|
146
|
+
self.aesthetics = [name]
|
|
147
|
+
if isinstance(given, dict):
|
|
148
|
+
given = given.get(self.key, given.get(name))
|
|
149
|
+
if hasattr(values, "to_numpy") and not hasattr(values, "__array_interface__"):
|
|
150
|
+
values = values.to_numpy()
|
|
151
|
+
if hasattr(values, "tolist") and not isinstance(values, list):
|
|
152
|
+
values = values.tolist()
|
|
153
|
+
seen = dict.fromkeys(values) # first appearance
|
|
154
|
+
gone = [k for k in seen if _missing(k) or (isinstance(k, float) and math.isnan(k))]
|
|
155
|
+
found = [k for k in seen if not any(k is g for g in gone)]
|
|
156
|
+
first = list(given or [])
|
|
157
|
+
rest = [k for k in (order or []) if k in seen and k not in first]
|
|
158
|
+
levels = first + rest + [k for k in found if k not in first and k not in rest]
|
|
159
|
+
if gone and missing == "keep" and "nan" not in levels:
|
|
160
|
+
levels.append("nan")
|
|
161
|
+
index = {k: i for i, k in enumerate(levels)}
|
|
162
|
+
for k in gone:
|
|
163
|
+
index[k] = index["nan"] if missing == "keep" else -1
|
|
164
|
+
self.levels = levels
|
|
165
|
+
self.codes = array.array("q", map(index.__getitem__, values))
|
|
166
|
+
used = {index[k] for k in seen}
|
|
167
|
+
self.present = [lv for i, lv in enumerate(levels) if i in used]
|
|
168
|
+
|
|
169
|
+
def take(self, rows: Any) -> None:
|
|
170
|
+
self.codes = array.array("q", (int(c) for c in kernels.take(
|
|
171
|
+
array.array("d", self.codes), rows)))
|
|
172
|
+
used = set(self.codes)
|
|
173
|
+
self.present = [lv for i, lv in enumerate(self.levels) if i in used]
|
|
174
|
+
|
|
175
|
+
def value(self, series: Any, name: str, level: Any) -> Any:
|
|
176
|
+
"""The value of aesthetic ``name`` (not colour) for ``level``."""
|
|
177
|
+
k = self.levels.index(level)
|
|
178
|
+
if name == "size":
|
|
179
|
+
from .sizemaps import SizeMap
|
|
180
|
+
sm = series.resolve("size_map") if "size_map" in series.SETTINGS else None
|
|
181
|
+
lo, hi = (sm or SizeMap()).diameters(None if isinstance(series, _Probe)
|
|
182
|
+
else series)
|
|
183
|
+
t = k / (len(self.levels) - 1) if len(self.levels) > 1 else 1.0
|
|
184
|
+
return math.sqrt(lo * lo + t * (hi * hi - lo * lo))
|
|
185
|
+
from .tables import LINE_STYLE_CYCLE, MARKER_CYCLE
|
|
186
|
+
given = series.resolve(f"{name}s") if f"{name}s" in series.SETTINGS else None
|
|
187
|
+
if isinstance(given, dict) and level in given:
|
|
188
|
+
return given[level]
|
|
189
|
+
cycle = given if isinstance(given, list) else (
|
|
190
|
+
MARKER_CYCLE if name == "marker" else LINE_STYLE_CYCLE)
|
|
191
|
+
return cycle[k % len(cycle)]
|
|
192
|
+
|
|
193
|
+
def __repr__(self) -> str:
|
|
194
|
+
return f"LevelAxis({self.key!r}, {self.levels!r})"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Section:
|
|
198
|
+
"""A legend section's header (a level axis' title)."""
|
|
199
|
+
|
|
200
|
+
header = True
|
|
201
|
+
|
|
202
|
+
def __init__(self, label: Any) -> None:
|
|
203
|
+
self.label = label
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Levels:
|
|
207
|
+
"""The level axes of a series and the calls it is drawn as (``parts``)."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, axes: list[LevelAxis], color: LevelAxis | None) -> None:
|
|
210
|
+
self.axes = axes
|
|
211
|
+
self.color = color
|
|
212
|
+
#: ``(series, colour spec, legend-only)`` of each call, in drawing order
|
|
213
|
+
self.parts: list[tuple[Any, Any, bool]] = []
|
|
214
|
+
self.headers: dict[int, Section] = {}
|
|
215
|
+
|
|
216
|
+
def item_colors(self, series: Any, ctx: Any) -> Any:
|
|
217
|
+
"""Each point's colour (items types): its level's, through ``kernels.take``."""
|
|
218
|
+
from .color.values import rgba_of
|
|
219
|
+
from .layers.paint import memo
|
|
220
|
+
from .spec import auto_colors
|
|
221
|
+
|
|
222
|
+
def make() -> Any:
|
|
223
|
+
table = array.array("d")
|
|
224
|
+
for c in auto_colors(series) or []:
|
|
225
|
+
rgba = rgba_of(ctx.color(c)) or (0.0, 0.0, 0.0, 0.0)
|
|
226
|
+
table.extend(rgba if len(rgba) == 4 else (*rgba, 1.0))
|
|
227
|
+
rows = memoryview(table).cast("B").cast("d", (len(table) // 4, 4))
|
|
228
|
+
flat = kernels.take(rows, self.color.codes) # type: ignore[union-attr]
|
|
229
|
+
n = len(flat) // 4
|
|
230
|
+
return memoryview(flat).cast("B").cast("d", (n, 4)) if n else []
|
|
231
|
+
return memo(ctx, ("levels", id(series)), make)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def make(cls: Any, cols: Any, pos: dict[str, Any], kw: dict[str, Any],
|
|
235
|
+
found: list[tuple[str, Any, Any, Any]]) -> Any:
|
|
236
|
+
"""The series of a call with levels (``found``, from :func:`detect`)."""
|
|
237
|
+
n = len(found[0][1])
|
|
238
|
+
if "group" in kw:
|
|
239
|
+
raise ValueError(f"{cls.__name__}: group= and levels in {found[0][0]}: use one")
|
|
240
|
+
given = level_order(kw["level_order"]) if kw.get("level_order") is not None else None
|
|
241
|
+
axes: dict[str, LevelAxis] = {}
|
|
242
|
+
for name, v, title, order in found:
|
|
243
|
+
if len(v) != n:
|
|
244
|
+
raise ValueError(f"{name} has {len(v)} levels for {n} in {found[0][0]}")
|
|
245
|
+
ax = LevelAxis(name, v, title, order, given, kw.get("level_missing", "keep"))
|
|
246
|
+
if ax.key in axes:
|
|
247
|
+
axes[ax.key].aesthetics.append(name)
|
|
248
|
+
else:
|
|
249
|
+
axes[ax.key] = ax
|
|
250
|
+
every = list(axes.values())
|
|
251
|
+
if any(-1 in ax.codes for ax in every): # level_missing="drop"
|
|
252
|
+
keep = array.array("q", (i for i in range(n) if all(ax.codes[i] >= 0 for ax in every)))
|
|
253
|
+
for ax in every:
|
|
254
|
+
ax.take(keep)
|
|
255
|
+
cols, pos, kw, n = (*_rows(cols, pos, kw, keep, n), len(keep))
|
|
256
|
+
color = next((ax for ax in every if "color" in ax.aesthetics), None)
|
|
257
|
+
title = kw.pop("name", None)
|
|
258
|
+
items = cls.LEVELS == "items"
|
|
259
|
+
for ax in every if items else (): # per item, but colours
|
|
260
|
+
for a in ax.aesthetics:
|
|
261
|
+
if a != "color":
|
|
262
|
+
values = [ax.value(_Probe(cls, kw), a, lv) for lv in ax.levels]
|
|
263
|
+
kw[a] = (kernels.take(array.array("d", values), ax.codes) if a == "size"
|
|
264
|
+
else [values[c] for c in ax.codes])
|
|
265
|
+
s = cls._make(cols, pos, kw)
|
|
266
|
+
lv = s._lv = Levels(every, color)
|
|
267
|
+
if title is None and len(every) == 1:
|
|
268
|
+
title = every[0].title
|
|
269
|
+
if title is not None:
|
|
270
|
+
s.plot._legend_title = title
|
|
271
|
+
if items:
|
|
272
|
+
lv.parts.append((s, ("items", color) if color is not None else None, False))
|
|
273
|
+
token = object() # members drawn in data order
|
|
274
|
+
for rows, combo in () if items else _combos(every, n):
|
|
275
|
+
m = _part(s, *_rows(cols, pos, kw, rows, n), dict(zip(every, combo)),
|
|
276
|
+
str(combo[0]) if len(every) == 1 else None)
|
|
277
|
+
m._combo = combo
|
|
278
|
+
m.__dict__["_order_rows"] = (token, rows)
|
|
279
|
+
lv.parts.append((m, None if s.is_set("color") else ("of", s) if color is None
|
|
280
|
+
else ("axis", color, combo[every.index(color)]), False))
|
|
281
|
+
for ax in every if len(every) > 1 or items else (): # legend sections
|
|
282
|
+
for k, level in enumerate(ax.present):
|
|
283
|
+
if items:
|
|
284
|
+
one = array.array("q", [ax.codes.index(ax.levels.index(level))])
|
|
285
|
+
got = _rows(cols, pos, kw, one, n)
|
|
286
|
+
else:
|
|
287
|
+
m = next(p for p, _, only in lv.parts
|
|
288
|
+
if not only and p._combo[every.index(ax)] == level)
|
|
289
|
+
got = (m.cols, m._pos, m._given)
|
|
290
|
+
m = _part(s, *got, {ax: level}, str(level))
|
|
291
|
+
lv.parts.append((m, ("axis", ax, level) if ax is color else ("text",)
|
|
292
|
+
if color is not None else None if s.is_set("color")
|
|
293
|
+
else ("of", s), True))
|
|
294
|
+
if k == 0 and len(every) > 1:
|
|
295
|
+
lv.headers[id(m)] = Section(ax.title if ax.title is not None
|
|
296
|
+
else ax.aesthetics[0])
|
|
297
|
+
return s
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _rows(cols: Any, pos: dict[str, Any], kw: dict[str, Any], rows: Any, n: int
|
|
301
|
+
) -> tuple[Any, dict[str, Any], dict[str, Any]]:
|
|
302
|
+
"""The columns, data arguments and per-point keywords of ``rows`` (of ``n``)."""
|
|
303
|
+
from .layers.base import SPLIT_KEYS
|
|
304
|
+
from .tables import take
|
|
305
|
+
ck = cols.take(rows, n)
|
|
306
|
+
pk = {k: ck.args[k] if k in ck.args else (take(v, rows) if _length(v) == n else v)
|
|
307
|
+
for k, v in pos.items()}
|
|
308
|
+
kk = {k: (take(v, rows) if k in SPLIT_KEYS and _length(v) == n and not (
|
|
309
|
+
isinstance(v, tuple) and len(v) in (3, 4)) else v) for k, v in kw.items()}
|
|
310
|
+
return ck, pk, kk
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _Probe:
|
|
314
|
+
"""The settings of a call before its series exists (level values at intake)."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, cls: Any, kw: dict[str, Any]) -> None:
|
|
317
|
+
self.SETTINGS = cls.SETTINGS
|
|
318
|
+
self.kw = kw
|
|
319
|
+
|
|
320
|
+
def resolve(self, name: str) -> Any:
|
|
321
|
+
v = self.kw.get(name)
|
|
322
|
+
return None if v is None else self.SETTINGS[name].check(v)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _combos(axes: list[LevelAxis], n: int) -> list[tuple[Any, tuple[Any, ...]]]:
|
|
326
|
+
"""``(rows, levels)`` of each level combination with rows, in level order (rows by
|
|
327
|
+
a stable argsort of the combined codes)."""
|
|
328
|
+
from bisect import bisect_left, bisect_right
|
|
329
|
+
sizes = [len(ax.levels) for ax in axes]
|
|
330
|
+
code = array.array("d", axes[-1].codes)
|
|
331
|
+
stride = sizes[-1]
|
|
332
|
+
for ax, size in reversed(list(zip(axes[:-1], sizes[:-1]))):
|
|
333
|
+
c = ax.codes
|
|
334
|
+
for i in range(n):
|
|
335
|
+
code[i] += c[i] * stride
|
|
336
|
+
stride *= size
|
|
337
|
+
rows = kernels.argsort(code)
|
|
338
|
+
ordered = kernels.take(code, rows) if n else array.array("d")
|
|
339
|
+
out = []
|
|
340
|
+
for k in sorted(set(code)):
|
|
341
|
+
lo = bisect_left(ordered, k)
|
|
342
|
+
combo, rest = [], int(k)
|
|
343
|
+
for size in reversed(sizes):
|
|
344
|
+
rest, j = divmod(rest, size)
|
|
345
|
+
combo.append(j)
|
|
346
|
+
out.append((rows[lo:bisect_right(ordered, k, lo)],
|
|
347
|
+
tuple(ax.levels[j] for ax, j in zip(axes, reversed(combo)))))
|
|
348
|
+
return out
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _upward(name: str) -> bool:
|
|
352
|
+
"""Whether ``name`` is a setting of the plot or the figure (a call's keyword that
|
|
353
|
+
went to them)."""
|
|
354
|
+
from .figure import Figure
|
|
355
|
+
from .plot import Plot
|
|
356
|
+
from .settings import canonical
|
|
357
|
+
return (name in Plot.CALL_EXTRA or canonical(name, Plot.SETTINGS) in Plot.SETTINGS
|
|
358
|
+
or canonical(name, Figure.SETTINGS) in Figure.SETTINGS)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _part(s: Any, ck: Any, pk: dict[str, Any], kw: dict[str, Any],
|
|
362
|
+
levels: dict[LevelAxis, Any], name: str | None) -> Any:
|
|
363
|
+
"""A call of ``s`` drawn apart (not in ``plot.series``): a level member, or a legend
|
|
364
|
+
section's entry (the axes not in ``levels`` at their defaults)."""
|
|
365
|
+
from .settings import canonical
|
|
366
|
+
own = type(s).SETTINGS
|
|
367
|
+
kk = {k: v for k, v in kw.items() # the plot's and figure's went to them
|
|
368
|
+
if canonical(k, own) in own or not _upward(k)}
|
|
369
|
+
for ax in s._lv.axes:
|
|
370
|
+
for a in ax.aesthetics:
|
|
371
|
+
if a != "color":
|
|
372
|
+
kk.pop(a, None)
|
|
373
|
+
if ax in levels:
|
|
374
|
+
kk[a] = ax.value(s, a, levels[ax])
|
|
375
|
+
if name is not None:
|
|
376
|
+
kk["name"] = name
|
|
377
|
+
m = object.__new__(type(s))
|
|
378
|
+
m.cols, m._of, m._pos, m._given = ck, s, pk, kk
|
|
379
|
+
m.__init__(**pk, **{**kk, "plot": s.plot})
|
|
380
|
+
return m
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# -- render ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
def calls_of(series: Any) -> list[Any]:
|
|
386
|
+
"""The calls a series is drawn as: its own, or its level members' and legend
|
|
387
|
+
sections'."""
|
|
388
|
+
lv = getattr(series, "_lv", None)
|
|
389
|
+
if lv is None:
|
|
390
|
+
return [series.layers()]
|
|
391
|
+
out = []
|
|
392
|
+
for part, spec, legend_only in lv.parts:
|
|
393
|
+
c = part.layers()
|
|
394
|
+
if legend_only:
|
|
395
|
+
keep = _key_call()(part)
|
|
396
|
+
keep.entries = c.entries
|
|
397
|
+
c = keep
|
|
398
|
+
head = lv.headers.get(id(part))
|
|
399
|
+
if head is not None:
|
|
400
|
+
c.entries = [head, *c.entries]
|
|
401
|
+
c.level = spec
|
|
402
|
+
if spec is not None:
|
|
403
|
+
c.color_count = 0
|
|
404
|
+
out.append(c)
|
|
405
|
+
return out
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@functools.cache
|
|
409
|
+
def _key_call() -> Any:
|
|
410
|
+
from .layers.core import Call
|
|
411
|
+
|
|
412
|
+
class Key(Call):
|
|
413
|
+
"""A legend section's entry: a call on no axis."""
|
|
414
|
+
|
|
415
|
+
def has_axis(self, role: str) -> bool:
|
|
416
|
+
return False
|
|
417
|
+
return Key
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def level_color_count(calls: list[Any]) -> int:
|
|
421
|
+
"""The plot's colours the level axes of ``calls`` take at most."""
|
|
422
|
+
seen: set[Any] = set()
|
|
423
|
+
n = 0
|
|
424
|
+
for c in calls:
|
|
425
|
+
spec = getattr(c, "level", None)
|
|
426
|
+
if spec is None:
|
|
427
|
+
continue
|
|
428
|
+
if spec[0] in ("axis", "items") and spec[1].key not in seen:
|
|
429
|
+
seen.add(spec[1].key)
|
|
430
|
+
n += len(spec[1].levels)
|
|
431
|
+
elif spec[0] == "of" and id(spec[1]) not in seen and not any(
|
|
432
|
+
d.series is spec[1] for d in calls):
|
|
433
|
+
seen.add(id(spec[1]))
|
|
434
|
+
n += 1
|
|
435
|
+
return n
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def assign(plot: Any, c: Any, cycle: Any, i: int, done: dict[Any, Any]) -> int:
|
|
439
|
+
"""The colours of a call with a colour spec (``c.level``) into the render; ``cycle``:
|
|
440
|
+
the plot's colours (in the series' colour variant); ``i``: the plot's next colour
|
|
441
|
+
(returned, advanced by the colours taken); ``done``: the series colours given in this
|
|
442
|
+
pass."""
|
|
443
|
+
from .spec import current
|
|
444
|
+
r = current()
|
|
445
|
+
assert r is not None
|
|
446
|
+
spec = c.level
|
|
447
|
+
kind = spec[0]
|
|
448
|
+
if kind == "text":
|
|
449
|
+
lg = getattr(plot, "legend", None)
|
|
450
|
+
col = lg.resolve("color") if lg is not None else None
|
|
451
|
+
r.colors[id(c.series)] = [col if col is not None else plot.resolve("text_color")]
|
|
452
|
+
return i
|
|
453
|
+
if kind == "of":
|
|
454
|
+
s = spec[1]
|
|
455
|
+
got = done.get(id(s))
|
|
456
|
+
if got is None:
|
|
457
|
+
got = done[id(s)] = [cycle[i % len(cycle)]]
|
|
458
|
+
i += 1
|
|
459
|
+
r.colors[id(c.series)] = got
|
|
460
|
+
return i
|
|
461
|
+
ax = spec[1]
|
|
462
|
+
owner = c.series._of if "_of" in c.series.__dict__ else c.series
|
|
463
|
+
group = r.levels_group.get(id(plot), id(plot))
|
|
464
|
+
store = r.levels.get((group, ax.key))
|
|
465
|
+
if store is None or (store[0] == id(plot) and (group, ax.key) not in done):
|
|
466
|
+
store = r.levels[(group, ax.key)] = (id(plot), dict[Any, Any]())
|
|
467
|
+
done[(group, ax.key)] = True
|
|
468
|
+
colors = store[1]
|
|
469
|
+
from .layers.pipeline import own_map
|
|
470
|
+
cm = own_map(owner)
|
|
471
|
+
by_level = cm if isinstance(cm, dict) else {}
|
|
472
|
+
own = None if cm is None or isinstance(cm, dict) else cm.colors(len(ax.levels))
|
|
473
|
+
for k, lv in enumerate(ax.levels):
|
|
474
|
+
if lv in colors:
|
|
475
|
+
continue
|
|
476
|
+
if lv in by_level:
|
|
477
|
+
colors[lv] = by_level[lv]
|
|
478
|
+
elif own is not None:
|
|
479
|
+
colors[lv] = own[k % len(own)]
|
|
480
|
+
else:
|
|
481
|
+
colors[lv] = cycle[i % len(cycle)]
|
|
482
|
+
i += 1
|
|
483
|
+
if kind == "items":
|
|
484
|
+
r.colors[id(c.series)] = [colors[lv] for lv in ax.levels]
|
|
485
|
+
else:
|
|
486
|
+
r.colors[id(c.series)] = [colors[spec[2]]]
|
|
487
|
+
return i
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def facet_orders(data: Any, kw: dict[str, Any]) -> None:
|
|
491
|
+
"""The ``level_order`` of a facet call: the levels of each level column over the
|
|
492
|
+
whole table, so that a level has one colour, marker… in every panel."""
|
|
493
|
+
from .tables import column, has_column
|
|
494
|
+
given = kw.get("level_order")
|
|
495
|
+
if given is not None and not isinstance(given, dict):
|
|
496
|
+
return
|
|
497
|
+
out = dict(given or {})
|
|
498
|
+
for name in AESTHETICS:
|
|
499
|
+
v = kw.get(name)
|
|
500
|
+
if not isinstance(v, str) or v in out or not has_column(data, v):
|
|
501
|
+
continue
|
|
502
|
+
values, order = column(data, v)
|
|
503
|
+
items = _items(values)
|
|
504
|
+
if order is None and not (items and any(isinstance(x, (str, bool)) for x in items)):
|
|
505
|
+
continue
|
|
506
|
+
ax = LevelAxis(name, values, v, order, None, kw.get("level_missing", "keep"))
|
|
507
|
+
out[v] = ax.levels
|
|
508
|
+
if out:
|
|
509
|
+
kw["level_order"] = out
|
plot_io/limits.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Limits: ``"auto"``, ``"lo/hi"`` strings (``min``, ``max``, numbers, ``q:F``), ``"-m/m"``
|
|
2
|
+
or pairs."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .times import axis_value
|
|
9
|
+
from .validators import typed
|
|
10
|
+
|
|
11
|
+
__all__ = ["check_limits", "resolve_limits", "symmetric", "SYMMETRIC"]
|
|
12
|
+
|
|
13
|
+
#: ``"-m/m"``: limits symmetric about the axis' ``center`` (or 0), ``m`` the farthest value
|
|
14
|
+
SYMMETRIC = (("m", -1.0), ("m", 1.0))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def symmetric(spec: Any) -> bool:
|
|
18
|
+
"""Whether the limits ``spec`` is :data:`SYMMETRIC`."""
|
|
19
|
+
return isinstance(spec, tuple) and len(spec) == 2 and all(
|
|
20
|
+
isinstance(s, tuple) and len(s) == 2 and isinstance(s[0], str) and s[0] == "m"
|
|
21
|
+
for s in spec)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _side(token: str) -> Any:
|
|
25
|
+
"""One side of a limits string: a keyword (any case), a number or an ISO date."""
|
|
26
|
+
t = token.strip().lower()
|
|
27
|
+
if t in ("min", "max", "auto", ""):
|
|
28
|
+
return "auto" if t in ("auto", "") else t
|
|
29
|
+
if t in ("m", "-m"):
|
|
30
|
+
raise ValueError(f"invalid limit {token!r}: `m` is only in \"-m/m\" (symmetric "
|
|
31
|
+
f"about the centre)")
|
|
32
|
+
if t.startswith("q:"):
|
|
33
|
+
try:
|
|
34
|
+
q = float(t[2:])
|
|
35
|
+
except ValueError:
|
|
36
|
+
q = math.nan
|
|
37
|
+
if not 0 <= q <= 1:
|
|
38
|
+
raise ValueError(f"invalid limit {token!r}: `q:` takes a quantile from 0 to 1")
|
|
39
|
+
return ("q", q)
|
|
40
|
+
try:
|
|
41
|
+
v = float(t)
|
|
42
|
+
except ValueError:
|
|
43
|
+
try:
|
|
44
|
+
v = axis_value(token.strip())
|
|
45
|
+
except ValueError:
|
|
46
|
+
raise ValueError(f"invalid limit {token!r}") from None
|
|
47
|
+
if not math.isfinite(v):
|
|
48
|
+
raise ValueError(f"invalid limit {token!r}")
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@typed("str | Sequence[Any]")
|
|
53
|
+
def check_limits(v: Any) -> Any:
|
|
54
|
+
"""Normalize a limits value to ``"auto"``, :data:`SYMMETRIC` (``"-m/m"``) or a pair of
|
|
55
|
+
sides (``None``/``"auto"``, a number, ``"min"``, ``"max"``, ``("q", F)``, or in a pair a
|
|
56
|
+
category ``("name", N)``)."""
|
|
57
|
+
if isinstance(v, str):
|
|
58
|
+
s = v.strip()
|
|
59
|
+
if s.lower() == "auto":
|
|
60
|
+
return "auto"
|
|
61
|
+
if "".join(s.lower().split()) == "-m/m":
|
|
62
|
+
return SYMMETRIC
|
|
63
|
+
parts = s.split("/")
|
|
64
|
+
if len(parts) != 2:
|
|
65
|
+
raise ValueError(f"expected 'lo/hi', got {v!r}")
|
|
66
|
+
return (_side(parts[0]), _side(parts[1]))
|
|
67
|
+
if symmetric(v):
|
|
68
|
+
return SYMMETRIC
|
|
69
|
+
if isinstance(v, (tuple, list)) and len(v) == 2:
|
|
70
|
+
out: list[Any] = []
|
|
71
|
+
for side in v:
|
|
72
|
+
if side is None:
|
|
73
|
+
out.append("auto")
|
|
74
|
+
elif isinstance(side, str):
|
|
75
|
+
try:
|
|
76
|
+
out.append(_side(side))
|
|
77
|
+
except ValueError: # a category name, read by the axis
|
|
78
|
+
out.append(("name", side))
|
|
79
|
+
elif isinstance(side, tuple) and len(side) == 2 and side[0] == "q":
|
|
80
|
+
out.append(_side(f"q:{side[1]!r}")) # already checked
|
|
81
|
+
else:
|
|
82
|
+
try:
|
|
83
|
+
x = axis_value(side) # a number or a date
|
|
84
|
+
except ValueError:
|
|
85
|
+
x = math.nan
|
|
86
|
+
if not math.isfinite(x):
|
|
87
|
+
raise ValueError(f"invalid limit {side!r}")
|
|
88
|
+
out.append(x)
|
|
89
|
+
return tuple(out)
|
|
90
|
+
raise ValueError(f"expected 'auto', 'lo/hi' or (lo, hi), got {v!r}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_limits(spec: Any, lo: float, hi: float,
|
|
94
|
+
quantile: Callable[[float], float] | None = None) -> tuple[Any, Any, bool, bool]:
|
|
95
|
+
"""``(lo, hi, lo_auto, hi_auto)`` from a limits spec and the data range; ``*_auto``
|
|
96
|
+
tells whether that side follows the data (and takes padding)."""
|
|
97
|
+
if symmetric(spec):
|
|
98
|
+
return lo, hi, False, False
|
|
99
|
+
sides = ("auto", "auto") if spec == "auto" else spec
|
|
100
|
+
out: list[Any] = []
|
|
101
|
+
autos: list[bool] = []
|
|
102
|
+
for side, data in zip(sides, (lo, hi)):
|
|
103
|
+
if side == "auto":
|
|
104
|
+
out.append(data)
|
|
105
|
+
autos.append(True)
|
|
106
|
+
elif side == "min":
|
|
107
|
+
out.append(lo)
|
|
108
|
+
autos.append(False)
|
|
109
|
+
elif side == "max":
|
|
110
|
+
out.append(hi)
|
|
111
|
+
autos.append(False)
|
|
112
|
+
elif isinstance(side, tuple):
|
|
113
|
+
out.append(quantile(side[1]) if quantile is not None else data)
|
|
114
|
+
autos.append(False)
|
|
115
|
+
else:
|
|
116
|
+
out.append(float(side))
|
|
117
|
+
autos.append(False)
|
|
118
|
+
return out[0], out[1], autos[0], autos[1]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def needs_quantiles(spec: Any) -> Sequence[float]:
|
|
122
|
+
if spec == "auto":
|
|
123
|
+
return ()
|
|
124
|
+
return [s[1] for s in spec if isinstance(s, tuple) and s[0] == "q"]
|
plot_io/lines.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Line styles: named dash patterns or dash lengths."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .validators import Custom, length, one_of, typed
|
|
6
|
+
|
|
7
|
+
__all__ = ["line_style", "dash_of"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DASH_LENGTH = length(lo=0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_STYLES = one_of("solid", "dashed", "dotted", "dashdot")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@typed("Literal['solid', 'dashed', 'dotted', 'dashdot'] | list[SupportsFloat | str] | "
|
|
17
|
+
"tuple[SupportsFloat | str, ...]")
|
|
18
|
+
def _line_style(v: Any) -> Any:
|
|
19
|
+
if isinstance(v, (list, tuple)):
|
|
20
|
+
return [_DASH_LENGTH(x) for x in v]
|
|
21
|
+
return _STYLES(v)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#: A line style: `solid`, `dashed`, `dotted`, `dashdot`, or dash lengths (px or any length
|
|
25
|
+
#: unit), alternately on and off.
|
|
26
|
+
line_style = Custom(_line_style, inner=_DASH_LENGTH)
|
|
27
|
+
|
|
28
|
+
_DASHES = {"solid": None, "dashed": (3.7, 1.6), "dotted": (1.0, 1.65),
|
|
29
|
+
"dashdot": (6.4, 1.6, 1.0, 1.6)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dash_of(style: Any, width: float) -> list[float] | None:
|
|
33
|
+
"""Dash lengths (px) of a line style, scaled by the line width."""
|
|
34
|
+
if style is None:
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(style, (list, tuple)):
|
|
37
|
+
return [float(x) for x in style] or None
|
|
38
|
+
pattern = _DASHES[style]
|
|
39
|
+
if pattern is None:
|
|
40
|
+
return None
|
|
41
|
+
w = max(width, 0.75)
|
|
42
|
+
return [x * w for x in pattern]
|