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
plot_io/stats.py ADDED
@@ -0,0 +1,140 @@
1
+ """Statistics and geometry of the derived plot types: bins, densities, contours, swarms.
2
+
3
+ The work is done in C by pdf_io's array helpers; these wrappers adapt their results to
4
+ plot_io's needs (matplotlib's hexagon lattice, rows of 2-D histograms, sorted LOWESS
5
+ fits, count rules for bins).
6
+ """
7
+
8
+ import math
9
+ from array import array
10
+ from typing import Any
11
+
12
+ from pdf_io import arrays as _arrays
13
+
14
+ from . import _backend
15
+
16
+ __all__ = ["bin_edges", "check_bins", "histogram", "histogram2d", "hexbin", "ecdf",
17
+ "bandwidth", "kde", "contour_lines", "contour_bands", "lowess", "swarm"]
18
+
19
+ _RULES = ("auto", "fd", "sturges", "scott", "sqrt", "rice", "doane")
20
+
21
+
22
+ def check_bins(v: Any) -> Any:
23
+ """A bin count, a rule name, or increasing edges."""
24
+ if isinstance(v, str):
25
+ if v not in _RULES:
26
+ raise ValueError(f"unknown bin rule {v!r} (use a count, edges or "
27
+ f"{', '.join(_RULES)})")
28
+ return v
29
+ if isinstance(v, int) and not isinstance(v, bool):
30
+ if v < 1:
31
+ raise ValueError("bins must be at least 1")
32
+ return v
33
+ e = _backend.doubles(v)
34
+ if len(e) < 2 or any(not b > a for a, b in zip(e, e[1:])):
35
+ raise ValueError("bin edges must increase")
36
+ return e
37
+
38
+
39
+ def bin_edges(values: Any, rule: Any = "auto", range: Any = None) -> array:
40
+ """Edges of even bins: ``rule`` is a count or one of numpy's rules."""
41
+ if not isinstance(rule, int):
42
+ return array("d", _arrays.bin_edges(values, rule, range))
43
+ if range is not None:
44
+ lo, hi = float(range[0]), float(range[1])
45
+ else:
46
+ n, lo, hi = _backend.extent(values, None)
47
+ if not n:
48
+ lo, hi = 0.0, 1.0
49
+ if lo == hi:
50
+ lo, hi = lo - 0.5, hi + 0.5
51
+ nb = max(1, rule)
52
+ edges = array("d", (lo + (hi - lo) * i / nb for i in builtins_range(nb + 1)))
53
+ edges[-1] = hi
54
+ return edges
55
+
56
+
57
+ builtins_range = range
58
+
59
+
60
+ def histogram(values: Any, edges: Any, weights: Any = None) -> array:
61
+ """Sum of weights (or counts) per bin; the last bin includes its right edge."""
62
+ return array("d", _arrays.histogram(values, edges, weights))
63
+
64
+
65
+ def histogram2d(x: Any, y: Any, x_edges: Any, y_edges: Any, weights: Any = None) -> list[array]:
66
+ """Counts per ``(y bin, x bin)``: rows are y bins from the first edge."""
67
+ h, w, flat = _arrays.histogram2d(x, y, x_edges, y_edges, weights)
68
+ return [array("d", flat[r * w:(r + 1) * w]) for r in builtins_range(h)]
69
+
70
+
71
+ _S3 = math.sqrt(3.0)
72
+
73
+
74
+ def hexbin(x: Any, y: Any, x0: float, y0: float, dx: float, dy: float, nx: int, ny: int,
75
+ weights: Any = None) -> tuple[array, array, array]:
76
+ """matplotlib's hexagonal bins: lattices ``(x0 + i dx, y0 + j dy)`` (``0 ≤ i ≤ nx``,
77
+ ``0 ≤ j ≤ ny``) and ``(x0 + (i + ½) dx, y0 + (j + ½) dy)``, each point to the nearest
78
+ centre with y distances weighted by 3. Returns the non-empty cells' centres and
79
+ sums."""
80
+ # In units (dx, dy / √3), matplotlib's metric is the plain distance pdf_io uses.
81
+ u = _backend.scaled(x, 1.0 / dx, -x0 / dx)
82
+ v = _backend.scaled(y, _S3 / dy, -_S3 * y0 / dy)
83
+ cx, cy, cs = _arrays.hexbin(u, v, 0.0, 0.0, 1.0, _S3, nx + 1, ny + 1, weights)
84
+ return (_backend.scaled(cx, dx, x0), _backend.scaled(cy, dy / _S3, y0), array("d", cs))
85
+
86
+
87
+ def ecdf(values: Any, weights: Any = None) -> tuple[array, array]:
88
+ """Sorted finite values and the cumulative weight up to each."""
89
+ xs, cs = _arrays.ecdf(values, weights)
90
+ return array("d", xs), array("d", cs)
91
+
92
+
93
+ def bandwidth(values: Any, rule: Any = "scott", weights: Any = None) -> float:
94
+ """Gaussian kernel bandwidth: Scott's or Silverman's factor times the (weighted) sample
95
+ standard deviation, as scipy's ``gaussian_kde``; a number is returned as is."""
96
+ if not isinstance(rule, str):
97
+ return float(rule)
98
+ return float(_arrays.bandwidth(values, rule, weights))
99
+
100
+
101
+ def kde(values: Any, grid_min: float, grid_max: float, n: int, bw: float, weights: Any = None,
102
+ cumulative: bool = False) -> array:
103
+ """Gaussian kernel density (integral 1 over the real line) at ``n`` points from
104
+ ``grid_min`` to ``grid_max``; ``cumulative``: its integral."""
105
+ return array("d", _arrays.kde(values, grid_min, grid_max, n, bw, weights, cumulative))
106
+
107
+
108
+ def contour_lines(grid: Any, w: int, h: int, xs: Any, ys: Any,
109
+ level: float) -> list[tuple[array, array, bool]]:
110
+ """Iso-lines at ``level`` as ``(x, y, closed)`` polylines (node coordinates ``xs``,
111
+ ``ys``; a closed line repeats its first point)."""
112
+ return [(array("d", a), array("d", b), bool(c))
113
+ for a, b, c in _arrays.contour_lines(grid, w, h, xs, ys, level)]
114
+
115
+
116
+ def contour_bands(grid: Any, w: int, h: int, xs: Any, ys: Any, lo: float,
117
+ hi: float) -> tuple[array, array, array]:
118
+ """The region ``lo <= v < hi`` as rings ``(xs, ys, ring_starts)`` that fill it exactly
119
+ together, in one path with the nonzero rule (pdf_io's form)."""
120
+ a, b, starts = _arrays.contour_bands(grid, w, h, xs, ys, lo, hi)
121
+ return array("d", a), array("d", b), array("q", (int(s) for s in starts))
122
+
123
+
124
+ def lowess(x: Any, y: Any, frac: float = 2.0 / 3.0, iterations: int = 3) -> tuple[array, array]:
125
+ """Locally weighted linear regression: the sorted x values and the fitted y values."""
126
+ xs0, ys0 = _backend.doubles(x), _backend.doubles(y)
127
+ pairs = sorted((a, b) for a, b in zip(xs0, ys0) if math.isfinite(a) and math.isfinite(b))
128
+ xs = array("d", (p[0] for p in pairs))
129
+ ys = array("d", (p[1] for p in pairs))
130
+ if len(xs) < 2:
131
+ return xs, ys
132
+ return xs, array("d", _arrays.lowess(xs, ys, frac, iterations))
133
+
134
+
135
+ def swarm(positions: Any, size: Any, slot_width: float) -> tuple[array, int]:
136
+ """Offsets across the slot for markers at ``positions`` (page units along the value
137
+ axis) of diameter ``size`` so that they do not overlap; points that do not fit are
138
+ put at the slot edge. Returns the offsets (in input order) and how many did not fit."""
139
+ offs, gutter = _arrays.swarm(positions, size, slot_width)
140
+ return array("d", offs), int(gutter)
plot_io/style.py ADDED
@@ -0,0 +1,71 @@
1
+ """Palette, palette references (``"C0"``…) and line dash styles."""
2
+
3
+ from typing import Any
4
+
5
+ from .defaults import OKABE_ITO
6
+
7
+ __all__ = ["PALETTE", "resolve_color", "dash_of", "with_alpha", "check_palette", "desaturate"]
8
+
9
+ PALETTE = OKABE_ITO
10
+
11
+
12
+ def check_palette(v: Any) -> Any:
13
+ """A palette name, a function ``n -> colors``, or a list of colors."""
14
+ from .palettes import check_palette as check
15
+ return check(v)
16
+
17
+
18
+ def resolve_color(c: Any, palette: tuple[Any, ...]) -> Any:
19
+ """A color with ``"C<n>"`` replaced by the palette entry."""
20
+ if isinstance(c, str) and len(c) >= 2 and c[0] == "C" and c[1:].isdigit():
21
+ return palette[int(c[1:]) % len(palette)]
22
+ if isinstance(c, str) and c.strip().lower() == "none":
23
+ return None
24
+ return c
25
+
26
+
27
+ def with_alpha(c: Any, alpha: float) -> Any:
28
+ """``c`` with its alpha multiplied by ``alpha`` (as an RGBA tuple)."""
29
+ if c is None:
30
+ return None
31
+ if alpha >= 1:
32
+ return c
33
+ from pdf_io.colors import CMYK, to_rgba
34
+
35
+ if isinstance(c, CMYK):
36
+ return c.with_alpha(c[3] * alpha)
37
+ rgba = to_rgba(c)
38
+ if rgba is None:
39
+ return None
40
+ a = rgba[3] if len(rgba) > 3 else 1.0
41
+ return (rgba[0], rgba[1], rgba[2], a * alpha)
42
+
43
+
44
+ def desaturate(c: Any, k: float) -> Any:
45
+ """``c`` with its HLS saturation multiplied by ``k`` (as an RGBA tuple)."""
46
+ import colorsys
47
+ from pdf_io.colors import to_rgba
48
+
49
+ rgba = to_rgba(c)
50
+ if rgba is None:
51
+ return None
52
+ h, light, s = colorsys.rgb_to_hls(rgba[0], rgba[1], rgba[2])
53
+ r, g, b = colorsys.hls_to_rgb(h, light, s * k)
54
+ return (r, g, b, rgba[3] if len(rgba) > 3 else 1.0)
55
+
56
+
57
+ _DASHES = {"solid": None, "dashed": (3.7, 1.6), "dotted": (1.0, 1.65),
58
+ "dashdot": (6.4, 1.6, 1.0, 1.6)}
59
+
60
+
61
+ def dash_of(style: Any, width: float) -> list[float] | None:
62
+ """Dash lengths (pt) of a line style, scaled by the line width."""
63
+ if style is None:
64
+ return None
65
+ if isinstance(style, (list, tuple)):
66
+ return [float(x) for x in style] or None
67
+ pattern = _DASHES[style]
68
+ if pattern is None:
69
+ return None
70
+ w = max(width, 0.5)
71
+ return [x * w for x in pattern]
@@ -0,0 +1,234 @@
1
+ """Styles: named presets of settings.
2
+
3
+ A style is a dict of settings: setting names for inherited settings (``"font_size"``) and
4
+ ``"Class.name"`` for any setting of a class (``"Axis.grid"``). The key ``"base"`` names
5
+ the styles it builds on, one or a list, applied left to right before its own keys. A
6
+ figure's ``style`` is a registered name or such a dict; its effective theme is the base
7
+ theme with the expanded style merged over it, lengths multiplied by ``scaling``.
8
+ """
9
+
10
+ import contextlib
11
+ import difflib
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+
15
+ from ..defaults import DEFAULTS, class_default
16
+ from ..settings import ALIASES, THEME, Setting
17
+
18
+ __all__ = ["register_style", "styles", "style_settings", "set_style", "use_style",
19
+ "check_style", "effective", "current"]
20
+
21
+ _STYLES: dict[str, dict[str, Any]] = {}
22
+ _version = 0
23
+ _session: Any = None
24
+ _stack: list[Any] = []
25
+
26
+
27
+ def _classes() -> dict[str, Any]:
28
+ from ..axis import Axis
29
+ from ..colorbar import Colorbar
30
+ from ..figure import Figure, FigureTitle
31
+ from ..plot import Legend, Plot, Title
32
+ from ..series.box import Box
33
+ from ..series.fill import Fill
34
+ from ..series.heatmap import Heatmap
35
+ from ..series.line import Line
36
+ from ..series.scatter import Scatter
37
+ from .. import decorations as d
38
+ from ..series import (area, bar, catpoints, contour, ecdf, errorbar, hexbin, hist, hist2d,
39
+ kde2d,
40
+ kde, pie, regression, stem, violin)
41
+ return {"Figure": Figure, "FigureTitle": FigureTitle, "Plot": Plot, "Title": Title,
42
+ "Legend": Legend, "Axis": Axis, "Colorbar": Colorbar, "Scatter": Scatter,
43
+ "Line": Line, "Fill": Fill, "Box": Box, "Heatmap": Heatmap, "Bar": bar.Bar,
44
+ "Hist": hist.Hist, "Kde": kde.Kde, "Kde2d": kde2d.Kde2d, "Ecdf": ecdf.Ecdf, "Violin": violin.Violin,
45
+ "Strip": catpoints.Strip, "Swarm": catpoints.Swarm, "Area": area.Area,
46
+ "Errorbar": errorbar.Errorbar, "Stem": stem.Stem,
47
+ "Regression": regression.Regression, "Hist2d": hist2d.Hist2d,
48
+ "Hexbin": hexbin.Hexbin, "Contour": contour.Contour, "Pie": pie.Pie,
49
+ "HLine": d.HLine, "VLine": d.VLine, "AxLine": d.AxLine, "HSpan": d.HSpan,
50
+ "VSpan": d.VSpan, "Text": d.Text, "Annotate": d.Annotate}
51
+
52
+
53
+ def _setting(key: str) -> Setting:
54
+ """The setting a style key refers to."""
55
+ classes = _classes()
56
+ if "." in key:
57
+ cls_name, _, name = key.partition(".")
58
+ cls = classes.get(cls_name)
59
+ if cls_name in ("XAxis", "YAxis"): # the x or y axis of plots only
60
+ cls = classes["Axis"]
61
+ if cls is None:
62
+ near = difflib.get_close_matches(cls_name, classes, n=1)
63
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
64
+ raise ValueError(f"unknown class {cls_name!r} in style key {key!r}{hint}")
65
+ s = cls.SETTINGS.get(name)
66
+ if s is None and cls_name == "Colorbar": # a setting of colorbar axes only
67
+ s = classes["Axis"].SETTINGS.get(name)
68
+ if s is None:
69
+ near = difflib.get_close_matches(name, cls.SETTINGS, n=1)
70
+ hint = f" (did you mean {cls_name}.{near[0]}?)" if near else ""
71
+ raise ValueError(f"unknown setting {key!r} in style{hint}")
72
+ return s
73
+ names: dict[str, Setting] = {}
74
+ for cls in classes.values():
75
+ for n, s in cls.SETTINGS.items():
76
+ if s.inherited:
77
+ names.setdefault(n, s)
78
+ if key not in names:
79
+ near = difflib.get_close_matches(key, names, n=1)
80
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
81
+ raise ValueError(f"unknown style key {key!r}: not an inherited setting; use "
82
+ f"\"Class.{key}\" for a setting of one class{hint}")
83
+ return names[key]
84
+
85
+
86
+ def _bases(v: Any) -> tuple[str, ...]:
87
+ names = (v,) if isinstance(v, str) else tuple(v)
88
+ for n in names:
89
+ if not isinstance(n, str):
90
+ raise ValueError(f"a style base is a style name, got {n!r}")
91
+ _get(n)
92
+ return names
93
+
94
+
95
+ def _validate(style: dict[str, Any]) -> dict[str, Any]:
96
+ out: dict[str, Any] = {}
97
+ for key, value in style.items():
98
+ if key == "base":
99
+ out["base"] = _bases(value)
100
+ continue
101
+ cls_part, dot, name = key.rpartition(".")
102
+ if name in ALIASES:
103
+ key = f"{cls_part}{dot}{ALIASES[name]}"
104
+ s = _setting(key)
105
+ if value is None:
106
+ raise ValueError(f"style key {key!r}: None is not a value")
107
+ if s.check is not None:
108
+ try:
109
+ value = s.check(value)
110
+ except (TypeError, ValueError) as e:
111
+ raise ValueError(f"style key {key!r}: {e}") from None
112
+ out[key] = value
113
+ return out
114
+
115
+
116
+ _loaded = False
117
+
118
+
119
+ def _load() -> None:
120
+ """Register the bundled styles (on first use: they refer to every settings class)."""
121
+ global _loaded
122
+ if not _loaded:
123
+ _loaded = True
124
+ from . import library # noqa: F401
125
+
126
+
127
+ def _get(name: str) -> dict[str, Any]:
128
+ _load()
129
+ key = name.strip().lower()
130
+ if key not in _STYLES:
131
+ near = difflib.get_close_matches(key, _STYLES, n=1)
132
+ hint = f" (did you mean {near[0]!r}?)" if near else ""
133
+ raise ValueError(f"unknown style {name!r}{hint}")
134
+ return _STYLES[key]
135
+
136
+
137
+ def register_style(name: str, style: dict[str, Any]) -> None:
138
+ """Register ``style`` (a dict of settings, with an optional ``"base"``) as ``name``."""
139
+ global _version
140
+ _load()
141
+ if not isinstance(style, dict):
142
+ raise ValueError("a style is a dict of settings")
143
+ _STYLES[name.strip().lower()] = _validate(style)
144
+ _version += 1
145
+
146
+
147
+ def styles() -> list[str]:
148
+ """Names of the registered styles."""
149
+ _load()
150
+ return sorted(_STYLES)
151
+
152
+
153
+ def check_style(v: Any) -> Any:
154
+ """Validator of the ``style`` setting: a registered name or a dict."""
155
+ if isinstance(v, str):
156
+ _get(v)
157
+ return v.strip().lower()
158
+ if isinstance(v, dict):
159
+ return _validate(v)
160
+ raise ValueError(f"a style is a style name or a dict of settings, got {v!r}")
161
+
162
+
163
+ def expand(style: Any, _seen: tuple[str, ...] = ()) -> dict[str, Any]:
164
+ """The settings of ``style`` with its bases applied first."""
165
+ if isinstance(style, str):
166
+ key = style.strip().lower()
167
+ if key in _seen:
168
+ raise ValueError(f"style {key!r} is its own base ({' → '.join(_seen + (key,))})")
169
+ d, seen = _get(key), _seen + (key,)
170
+ else:
171
+ d, seen = check_style(style), _seen
172
+ out: dict[str, Any] = {}
173
+ for b in d.get("base", ()):
174
+ out.update(expand(b, seen))
175
+ out.update((k, v) for k, v in d.items() if k != "base")
176
+ return out
177
+
178
+
179
+ def style_settings(style: Any) -> dict[str, Any]:
180
+ """The base theme with ``style`` merged over it (unscaled), to inspect or copy."""
181
+ return {**THEME, **expand(style)}
182
+
183
+
184
+ def _scaled(theme: dict[str, Any], s: float) -> dict[str, Any]:
185
+ def scale(v: Any) -> Any:
186
+ return v * s if isinstance(v, (int, float)) and not isinstance(v, bool) else v
187
+
188
+ out = dict(theme)
189
+ for key, value in theme.items():
190
+ if _setting(key).length:
191
+ out[key] = scale(value)
192
+ for cls_name, cls in _classes().items():
193
+ for name, setting in cls.SETTINGS.items():
194
+ key = f"{cls_name}.{name}"
195
+ if setting.length and not setting.inherited and key not in out:
196
+ d = class_default(cls, name)
197
+ if not callable(d) and scale(d) is not d:
198
+ out[key] = scale(d)
199
+ return out
200
+
201
+
202
+ def effective(style: Any, scaling: float | None) -> dict[str, Any]:
203
+ """The effective theme of a figure with ``style`` (``None``: the base theme)."""
204
+ theme = {**THEME, **expand(style)} if style is not None else dict(THEME)
205
+ s = scaling if scaling is not None else theme.get("Figure.scaling", DEFAULTS["Figure.scaling"])
206
+ return _scaled(theme, s) if s != 1.0 else theme
207
+
208
+
209
+ def version() -> int:
210
+ """Changes whenever a style is registered (for caches of effective themes)."""
211
+ _load()
212
+ return _version
213
+
214
+
215
+ def current() -> Any:
216
+ """The style new figures take: the innermost ``use_style``, else ``set_style``'s."""
217
+ return _stack[-1] if _stack else _session
218
+
219
+
220
+ def set_style(style: Any) -> None:
221
+ """Default style of figures created afterwards (``None``: back to ``"default"``)."""
222
+ global _session
223
+ _session = None if style is None else check_style(style)
224
+
225
+
226
+ @contextlib.contextmanager
227
+ def use_style(style: Any) -> Iterator[None]:
228
+ """Default style of figures created inside the block."""
229
+ _stack.append(check_style(style))
230
+ try:
231
+ yield
232
+ finally:
233
+ _stack.pop()
234
+