plot-io 0.1.0__py3-none-any.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 (65) hide show
  1. plot_io/__init__.py +66 -0
  2. plot_io/_backend.py +114 -0
  3. plot_io/axis.py +574 -0
  4. plot_io/colorbar.py +342 -0
  5. plot_io/decorations.py +496 -0
  6. plot_io/defaults.py +766 -0
  7. plot_io/density.py +97 -0
  8. plot_io/enums.py +63 -0
  9. plot_io/facets.py +184 -0
  10. plot_io/figure.py +331 -0
  11. plot_io/labels.py +63 -0
  12. plot_io/legend.py +498 -0
  13. plot_io/limits.py +92 -0
  14. plot_io/maps/__init__.py +365 -0
  15. plot_io/maps/data.py +333 -0
  16. plot_io/markers.py +117 -0
  17. plot_io/numbers.py +289 -0
  18. plot_io/occupancy.py +219 -0
  19. plot_io/palettes.py +142 -0
  20. plot_io/plot.py +435 -0
  21. plot_io/render.py +1122 -0
  22. plot_io/scales.py +93 -0
  23. plot_io/series/__init__.py +498 -0
  24. plot_io/series/area.py +181 -0
  25. plot_io/series/bar.py +294 -0
  26. plot_io/series/box.py +401 -0
  27. plot_io/series/catdata.py +23 -0
  28. plot_io/series/catpoints.py +182 -0
  29. plot_io/series/contour.py +294 -0
  30. plot_io/series/curves.py +128 -0
  31. plot_io/series/ecdf.py +143 -0
  32. plot_io/series/errorbar.py +111 -0
  33. plot_io/series/errors.py +100 -0
  34. plot_io/series/fill.py +168 -0
  35. plot_io/series/heatmap.py +304 -0
  36. plot_io/series/hexbin.py +216 -0
  37. plot_io/series/hist.py +379 -0
  38. plot_io/series/hist2d.py +163 -0
  39. plot_io/series/kde.py +228 -0
  40. plot_io/series/kde2d.py +153 -0
  41. plot_io/series/line.py +173 -0
  42. plot_io/series/pie.py +304 -0
  43. plot_io/series/points.py +341 -0
  44. plot_io/series/regression.py +250 -0
  45. plot_io/series/scatter.py +103 -0
  46. plot_io/series/stem.py +112 -0
  47. plot_io/series/violin.py +321 -0
  48. plot_io/settings.py +281 -0
  49. plot_io/shapes.py +57 -0
  50. plot_io/sharing.py +160 -0
  51. plot_io/slots.py +52 -0
  52. plot_io/stacking.py +63 -0
  53. plot_io/stats.py +140 -0
  54. plot_io/style.py +71 -0
  55. plot_io/styles/__init__.py +234 -0
  56. plot_io/styles/library.py +732 -0
  57. plot_io/tables.py +141 -0
  58. plot_io/text.py +109 -0
  59. plot_io/ticks.py +284 -0
  60. plot_io/times.py +199 -0
  61. plot_io/values.py +161 -0
  62. plot_io-0.1.0.dist-info/METADATA +102 -0
  63. plot_io-0.1.0.dist-info/RECORD +65 -0
  64. plot_io-0.1.0.dist-info/WHEEL +4 -0
  65. plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,365 @@
1
+ """Color and size maps.
2
+
3
+ A :class:`ColorMap` is a color gradient plus the mapping of values onto it (limits and
4
+ scale); a :class:`SizeMap` maps values to marker diameters. Values stay on the series
5
+ (``color_values``, ``size_values``), so one map can serve several series.
6
+ """
7
+
8
+ import math
9
+ from collections.abc import Sequence
10
+ from typing import Any
11
+
12
+ from pdf_io.colors import to_rgba
13
+
14
+ from .. import _backend
15
+ from ..limits import check_limits
16
+ from ..scales import NUMERIC_SCALES
17
+ from ..settings import one_of
18
+ from .data import DATA
19
+
20
+ __all__ = ["ColorMap", "SizeMap", "register_color_map", "color_map_names"]
21
+
22
+ RGBA = tuple[float, float, float, float]
23
+ _check_scale = one_of(*NUMERIC_SCALES)
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Oklab
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _to_linear(c: float) -> float:
31
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
32
+
33
+
34
+ def _from_linear(c: float) -> float:
35
+ c = min(1.0, max(0.0, c))
36
+ return 12.92 * c if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
37
+
38
+
39
+ def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
40
+ r, g, b = _to_linear(r), _to_linear(g), _to_linear(b)
41
+ lc = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) ** (1 / 3)
42
+ mc = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) ** (1 / 3)
43
+ sc = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) ** (1 / 3)
44
+ return (0.2104542553 * lc + 0.7936177850 * mc - 0.0040720468 * sc,
45
+ 1.9779984951 * lc - 2.4285922050 * mc + 0.4505937099 * sc,
46
+ 0.0259040371 * lc + 0.7827717662 * mc - 0.8086757660 * sc)
47
+
48
+
49
+ def oklab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]:
50
+ lc = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
51
+ mc = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
52
+ sc = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
53
+ return (_from_linear(4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc),
54
+ _from_linear(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc),
55
+ _from_linear(-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc))
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Color maps
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def _rgba(color: Any) -> RGBA:
63
+ try:
64
+ c = to_rgba(color)
65
+ except (TypeError, ValueError):
66
+ c = None
67
+ if c is None:
68
+ raise ValueError(f"invalid color {color!r}")
69
+ return (float(c[0]), float(c[1]), float(c[2]), float(c[3]) if len(c) > 3 else 1.0)
70
+
71
+
72
+ def _hex_stops(data: str) -> list[tuple[float, RGBA]]:
73
+ colors = [(int(data[i:i + 2], 16) / 255, int(data[i + 2:i + 4], 16) / 255,
74
+ int(data[i + 4:i + 6], 16) / 255, 1.0) for i in range(0, len(data), 6)]
75
+ n = len(colors) - 1
76
+ return [(i / n, c) for i, c in enumerate(colors)]
77
+
78
+
79
+ #: Registered maps: name → (stops, space).
80
+ _REGISTRY: dict[str, tuple[list[tuple[float, RGBA]], str]] = {
81
+ "grey": ([(0.0, (0.0, 0.0, 0.0, 1.0)), (1.0, (1.0, 1.0, 1.0, 1.0))], "srgb"),
82
+ }
83
+ _REGISTRY.update((name, (_hex_stops("".join(d)), "srgb")) for name, d in DATA.items())
84
+ #: ColorBrewer's YlGnBu and Blues (Cynthia Brewer, Apache-2.0): Vega-Lite's heatmap and ramp
85
+ _REGISTRY["ylgnbu"] = (_hex_stops("ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58"),
86
+ "srgb")
87
+ _REGISTRY["blues"] = (_hex_stops("f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b"),
88
+ "srgb")
89
+ #: ggplot2's continuous default (``scale_colour_gradient``), interpolated perceptually
90
+ _REGISTRY["ggplot2"] = ([(0.0, (0x13 / 255, 0x2B / 255, 0x43 / 255, 1.0)),
91
+ (1.0, (0x56 / 255, 0xB1 / 255, 0xF7 / 255, 1.0))], "oklab")
92
+ _ALIASES = {"gray": "grey"}
93
+
94
+
95
+ def color_map_names() -> list[str]:
96
+ return sorted(_REGISTRY)
97
+
98
+
99
+ def register_color_map(name: str, color_map: Any) -> None:
100
+ """Register a map under ``name`` (also usable reversed, as ``name + "_r"``)."""
101
+ cm = color_map if isinstance(color_map, ColorMap) else ColorMap(color_map)
102
+ _REGISTRY[name.strip().lower()] = (list(cm.stops), cm.space)
103
+
104
+
105
+ def _lookup(name: str) -> tuple[list[tuple[float, RGBA]], str, str]:
106
+ key = name.strip().lower()
107
+ reverse = key.endswith("_r")
108
+ base = key[:-2] if reverse else key
109
+ base = _ALIASES.get(base, base)
110
+ if base not in _REGISTRY:
111
+ raise ValueError(f"unknown color map {name!r} (bundled: {', '.join(color_map_names())})")
112
+ stops, space = _REGISTRY[base]
113
+ if reverse:
114
+ stops = [(1.0 - p, c) for p, c in reversed(stops)]
115
+ return stops, space, key
116
+
117
+
118
+ class ColorMap:
119
+ """A color gradient and the mapping of values onto it.
120
+
121
+ ``colors``: a registered name (``"viridis"``, ``"viridis_r"``), a list of colors
122
+ (evenly spaced, or at ``positions``), another ``ColorMap`` (copied) or a function
123
+ ``t -> color``. ``limits`` and ``scale`` use the axis syntax; ``"auto"`` limits cover
124
+ every series using the map. ``under``/``over``: colors below and above the limits
125
+ (default: the end colors); ``bad``: color of missing values (default: transparent).
126
+ ``space``: interpolation between the given colors, ``"oklab"`` or ``"srgb"``.
127
+ """
128
+
129
+ space: str
130
+ name: str | None
131
+ under: RGBA | None
132
+ over: RGBA | None
133
+ bad: RGBA | None
134
+
135
+ def __init__(self, colors: Any = "viridis", positions: Sequence[float] | None = None,
136
+ limits: Any = "auto", scale: str = "linear", under: Any = None,
137
+ over: Any = None, bad: Any = None, space: str | None = None,
138
+ name: str | None = None) -> None:
139
+ self.interpolation = "linear"
140
+ default_space = "oklab"
141
+ stops: list[tuple[float, RGBA]]
142
+ if isinstance(colors, ColorMap):
143
+ stops, default_space = list(colors.stops), colors.space
144
+ self.interpolation = colors.interpolation
145
+ name = name or colors.name
146
+ under = colors.under if under is None else under
147
+ over = colors.over if over is None else over
148
+ bad = colors.bad if bad is None else bad
149
+ elif isinstance(colors, str):
150
+ stops, default_space, key = _lookup(colors)
151
+ name = name or key
152
+ elif callable(colors):
153
+ stops = [(i / 255, _rgba(colors(i / 255))) for i in range(256)]
154
+ default_space = "srgb"
155
+ else:
156
+ cols = [_rgba(c) for c in colors]
157
+ if not cols:
158
+ raise ValueError("a color map needs at least one color")
159
+ if positions is None:
160
+ n = max(len(cols) - 1, 1)
161
+ stops = [(i / n, c) for i, c in enumerate(cols)]
162
+ else:
163
+ pos = [float(p) for p in positions]
164
+ if len(pos) != len(cols):
165
+ raise ValueError("give one position per color")
166
+ if any(b < a for a, b in zip(pos, pos[1:])) or pos[0] < 0 or pos[-1] > 1:
167
+ raise ValueError("positions must increase within [0, 1]")
168
+ stops = list(zip(pos, cols))
169
+ if len(stops) == 1:
170
+ stops = [(0.0, stops[0][1]), (1.0, stops[0][1])]
171
+ self.stops: list[tuple[float, RGBA]] = stops
172
+ self.space = one_of("oklab", "srgb")(space) if space is not None else default_space
173
+ self.limits = check_limits(limits)
174
+ self.scale = _check_scale(scale)
175
+ self.under = None if under is None else _rgba(under)
176
+ self.over = None if over is None else _rgba(over)
177
+ self.bad = None if bad is None else _rgba(bad)
178
+ self.name = name
179
+ self._lab = ([srgb_to_oklab(*c[:3]) for _, c in stops] if self.space == "oklab"
180
+ else None)
181
+ self._pdf: Any = None
182
+
183
+ def __repr__(self) -> str:
184
+ return f"ColorMap({self.name or f'{len(self.stops)} colors'})"
185
+
186
+ def _copy(self, **changes: Any) -> "ColorMap":
187
+ args = dict(limits=self.limits, scale=self.scale, under=self.under, over=self.over,
188
+ bad=self.bad, space=self.space, name=self.name)
189
+ args.update(changes)
190
+ stops = changes.pop("stops", None)
191
+ args.pop("stops", None)
192
+ cm = ColorMap(self, **args)
193
+ cm.name = args["name"]
194
+ if stops is not None:
195
+ cm.stops = stops
196
+ cm._lab = ([srgb_to_oklab(*c[:3]) for _, c in stops] if cm.space == "oklab"
197
+ else None)
198
+ return cm
199
+
200
+ def __call__(self, t: float) -> RGBA:
201
+ """Color at ``t`` in [0, 1] (``under``/``over`` outside, ``bad`` for NaN)."""
202
+ t = float(t)
203
+ if t != t:
204
+ return self.bad or (0.0, 0.0, 0.0, 0.0)
205
+ if t < 0:
206
+ return self.under or self.stops[0][1]
207
+ if t > 1:
208
+ return self.over or self.stops[-1][1]
209
+ stops = self.stops
210
+ if self.interpolation == "nearest":
211
+ for i in range(len(stops) - 1, -1, -1):
212
+ if stops[i][0] <= t:
213
+ return stops[i][1]
214
+ return stops[0][1]
215
+ if t <= stops[0][0]:
216
+ return stops[0][1]
217
+ if t >= stops[-1][0]:
218
+ return stops[-1][1]
219
+ lo, hi = 0, len(stops) - 1
220
+ while hi - lo > 1:
221
+ mid = (lo + hi) // 2
222
+ if stops[mid][0] <= t:
223
+ lo = mid
224
+ else:
225
+ hi = mid
226
+ (p0, c0), (p1, c1) = stops[lo], stops[hi]
227
+ f = (t - p0) / (p1 - p0) if p1 > p0 else 1.0
228
+ alpha = c0[3] + (c1[3] - c0[3]) * f
229
+ if self._lab is not None:
230
+ a, b = self._lab[lo], self._lab[hi]
231
+ r, g, bl = oklab_to_srgb(*(x + (y - x) * f for x, y in zip(a, b)))
232
+ return (r, g, bl, alpha)
233
+ return (c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f,
234
+ c0[2] + (c1[2] - c0[2]) * f, alpha)
235
+
236
+ def table(self, n: int = 256) -> list[RGBA]:
237
+ return [self(i / (n - 1)) for i in range(n)] if n > 1 else [self(0.0)]
238
+
239
+ def colors_at(self, ts: Any) -> Any:
240
+ """Colors at many positions in [0, 1] (an ``(n, 4)`` buffer, or a list)."""
241
+ return _backend.colors_at(self.to_pdf_io(), ts)
242
+
243
+ def reversed(self) -> "ColorMap":
244
+ stops = [(1.0 - p, c) for p, c in reversed(self.stops)]
245
+ name = None
246
+ if self.name:
247
+ name = self.name[:-2] if self.name.endswith("_r") else self.name + "_r"
248
+ return self._copy(stops=stops, under=self.over, over=self.under, name=name)
249
+
250
+ def resampled(self, n: int) -> "ColorMap":
251
+ """``n`` flat steps (a discrete map)."""
252
+ if n < 1:
253
+ raise ValueError("resampled() needs at least one step")
254
+ cols = [self((i + 0.5) / n) for i in range(n)]
255
+ cm = self._copy(stops=[(i / n, c) for i, c in enumerate(cols)], space="srgb")
256
+ cm.interpolation = "nearest"
257
+ return cm
258
+
259
+ def truncated(self, lo: float, hi: float) -> "ColorMap":
260
+ """The part between ``lo`` and ``hi``, stretched to [0, 1]."""
261
+ if not 0 <= lo < hi <= 1:
262
+ raise ValueError("truncated() needs 0 <= lo < hi <= 1")
263
+ n = 256
264
+ cols = [self(lo + (hi - lo) * i / (n - 1)) for i in range(n)]
265
+ return self._copy(stops=[(i / (n - 1), c) for i, c in enumerate(cols)], space="srgb",
266
+ name=None)
267
+
268
+ def with_extremes(self, under: Any = None, over: Any = None, bad: Any = None) -> "ColorMap":
269
+ return self._copy(under=under if under is not None else self.under,
270
+ over=over if over is not None else self.over,
271
+ bad=bad if bad is not None else self.bad)
272
+
273
+ def pdf_stops(self) -> list[tuple[float, RGBA]]:
274
+ """Stops for pdf_io (sRGB interpolation): Oklab maps as 256 samples."""
275
+ if self.space == "oklab" and len(self.stops) < 256 and self.interpolation == "linear":
276
+ return [(i / 255, self(i / 255)) for i in range(256)]
277
+ return list(self.stops)
278
+
279
+ def to_pdf_io(self) -> Any:
280
+ """The equivalent pdf_io color map."""
281
+ if self._pdf is None:
282
+ self._pdf = _backend.pdf_color_map(self.pdf_stops(), self.interpolation,
283
+ self.under, self.over, self.bad)
284
+ return self._pdf
285
+
286
+
287
+ def check_cmap(v: Any) -> Any:
288
+ """A ``ColorMap``, or anything its ``colors`` accepts (validated)."""
289
+ if isinstance(v, ColorMap):
290
+ return v
291
+ ColorMap(v)
292
+ return v
293
+
294
+
295
+ def as_color_map(value: Any) -> ColorMap:
296
+ return value if isinstance(value, ColorMap) else ColorMap(value)
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Size maps
301
+ # ---------------------------------------------------------------------------
302
+
303
+ def _size(v: Any) -> float:
304
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not v >= 0:
305
+ raise ValueError(f"expected a size >= 0, got {v!r}")
306
+ return float(v)
307
+
308
+
309
+ class SizeMap:
310
+ """Values to marker diameters (pt).
311
+
312
+ ``sizes``: the diameters at the two limits. ``by="area"`` moves the marker area
313
+ linearly between the end areas; ``by="diameter"`` moves the diameter. ``under`` and
314
+ ``over``: sizes below and above the limits (default: the end sizes; ``"none"`` leaves
315
+ those points out); ``bad``: size of points with a missing value (default: left out).
316
+ ``label``: title of its size key in legends (default: the ``size`` column name).
317
+ """
318
+
319
+ def __init__(self, sizes: Sequence[float] = (2, 10), limits: Any = "auto",
320
+ scale: str = "linear", by: str = "area", under: Any = None,
321
+ over: Any = None, bad: Any = None, label: Any = None) -> None:
322
+ self.label = label
323
+ if len(sizes) != 2:
324
+ raise ValueError("sizes must be a pair (smallest, largest)")
325
+ self.sizes = (_size(sizes[0]), _size(sizes[1]))
326
+ self.limits = check_limits(limits)
327
+ self.scale = _check_scale(scale)
328
+ self.by = one_of("area", "diameter")(by)
329
+ self.under = self._extreme(under)
330
+ self.over = self._extreme(over)
331
+ self.bad = self._extreme(bad)
332
+
333
+ @staticmethod
334
+ def _extreme(v: Any) -> Any:
335
+ if v is None:
336
+ return None
337
+ if isinstance(v, str) and v.lower() == "none":
338
+ return math.nan
339
+ return _size(v)
340
+
341
+ def __repr__(self) -> str:
342
+ return f"SizeMap({self.sizes[0]:g}–{self.sizes[1]:g} pt, by {self.by})"
343
+
344
+ def map(self, values: Any, flo: float, fhi: float, function: str | None) -> Any:
345
+ """Diameters for ``values``, with ``flo``/``fhi`` the limits after the scale
346
+ function."""
347
+ d0, d1 = self.sizes
348
+ span = fhi - flo
349
+ nan = math.nan
350
+ under = d0 if self.under is None else self.under
351
+ over = d1 if self.over is None else self.over
352
+ bad = nan if self.bad is None else self.bad
353
+ if self.by == "diameter":
354
+ k = (d1 - d0) / span if span else 0.0
355
+ lo, hi = (d0, d1) if d1 >= d0 else (d1, d0)
356
+ u, o = (under, over) if d1 >= d0 else (over, under)
357
+ return _backend.scaled(values, k, d0 - k * flo, function, low=lo, high=hi,
358
+ under=u, over=o, nan=bad)
359
+ a0, a1 = d0 * d0, d1 * d1
360
+ k = (a1 - a0) / span if span else 0.0
361
+ lo, hi = (a0, a1) if a1 >= a0 else (a1, a0)
362
+ u, o = (under * under, over * over) if a1 >= a0 else (over * over, under * under)
363
+ areas = _backend.scaled(values, k, a0 - k * flo, function, low=lo, high=hi,
364
+ under=u, over=o, nan=bad * bad)
365
+ return _backend.scaled(areas, 1.0, 0.0, "sqrt")