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.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/density.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Point density: a 2-D Gaussian kernel density estimate at each point of a series, as
|
|
2
|
+
scipy's ``gaussian_kde(np.array([x, y]))(np.array([x, y]))`` computes it.
|
|
3
|
+
|
|
4
|
+
The kernel covariance is the data covariance scaled by Scott's factor n^(-1/6) (or
|
|
5
|
+
Silverman's), and the density is computed on the axis positions (log axes in log space).
|
|
6
|
+
pdf_io does the work in C: an exact sum for small inputs, a binned and blurred grid for
|
|
7
|
+
large ones.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import _backend
|
|
14
|
+
from .settings import number, one_of
|
|
15
|
+
|
|
16
|
+
__all__ = ["Density"]
|
|
17
|
+
|
|
18
|
+
_check_normalize = one_of("pdf", "count", "max")
|
|
19
|
+
_check_method = one_of("auto", "exact", "grid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bandwidth(v: Any) -> Any:
|
|
23
|
+
if isinstance(v, str):
|
|
24
|
+
return one_of("scott", "silverman")(v)
|
|
25
|
+
if isinstance(v, (tuple, list)):
|
|
26
|
+
m = [list(map(float, row)) for row in v]
|
|
27
|
+
if len(m) != 2 or any(len(r) != 2 for r in m):
|
|
28
|
+
raise ValueError("a bandwidth matrix is 2 × 2")
|
|
29
|
+
if abs(m[0][1] - m[1][0]) > 1e-12 * max(1.0, abs(m[0][1])):
|
|
30
|
+
raise ValueError("a bandwidth matrix is symmetric")
|
|
31
|
+
if not (m[0][0] > 0 and m[1][1] > 0 and m[0][0] * m[1][1] - m[0][1] ** 2 > 0):
|
|
32
|
+
raise ValueError("a bandwidth matrix is positive definite")
|
|
33
|
+
return (m[0][0], m[0][1], m[1][1])
|
|
34
|
+
return number(positive=True)(v)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Density:
|
|
38
|
+
"""The local density of a series' points, as values for ``color``, ``size``, ``alpha``
|
|
39
|
+
or ``order``.
|
|
40
|
+
|
|
41
|
+
``bandwidth``: ``"scott"`` (default), ``"silverman"``, a number (isotropic standard
|
|
42
|
+
deviation in axis units) or a 2 × 2 covariance matrix; ``adjust`` multiplies it.
|
|
43
|
+
``normalize``: ``"pdf"`` (scipy's values), ``"count"`` (points per unit area) or
|
|
44
|
+
``"max"`` (0–1). ``method``: ``"exact"``, ``"grid"`` or ``"auto"`` (exact up to
|
|
45
|
+
20,000 points); ``grid``: grid size of the grid method.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, bandwidth: Any = "scott", adjust: float = 1.0, normalize: str = "pdf",
|
|
49
|
+
method: str = "auto", grid: int = 512) -> None:
|
|
50
|
+
self.bandwidth = _bandwidth(bandwidth)
|
|
51
|
+
self.adjust = number(positive=True)(adjust)
|
|
52
|
+
self.normalize = _check_normalize(normalize)
|
|
53
|
+
self.method = _check_method(method)
|
|
54
|
+
self.grid = int(number(lo=16, hi=4096, integer=True)(grid))
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"Density({self.bandwidth!r}, normalize={self.normalize!r})"
|
|
58
|
+
|
|
59
|
+
def label(self) -> str:
|
|
60
|
+
return {"pdf": "density", "count": "points per unit area",
|
|
61
|
+
"max": "relative density"}[self.normalize]
|
|
62
|
+
|
|
63
|
+
def compute(self, series: Any) -> Any:
|
|
64
|
+
"""The density at each point of ``series`` (its ``density_points()``)."""
|
|
65
|
+
xs, ys = series.density_points()
|
|
66
|
+
return self.at(xs, ys)
|
|
67
|
+
|
|
68
|
+
def at(self, xs: Any, ys: Any) -> Any:
|
|
69
|
+
"""The density at each point ``(xs[i], ys[i])`` (positions, not data)."""
|
|
70
|
+
xs, ys = _backend.doubles(xs), _backend.doubles(ys)
|
|
71
|
+
h = self.covariance(xs, ys)
|
|
72
|
+
if not all(math.isfinite(v) for v in h): # fewer than 2 distinct points
|
|
73
|
+
import array
|
|
74
|
+
return array.array("d", [math.nan]) * len(xs)
|
|
75
|
+
dens = _backend.kde2d_at(xs, ys, h, self.method, self.grid)
|
|
76
|
+
return self._normalized(dens, xs, ys)
|
|
77
|
+
|
|
78
|
+
def covariance(self, xs: Any, ys: Any) -> tuple[float, float, float]:
|
|
79
|
+
"""The kernel covariance ``(h11, h12, h22)``."""
|
|
80
|
+
a = self.adjust * self.adjust
|
|
81
|
+
if isinstance(self.bandwidth, tuple):
|
|
82
|
+
h11, h12, h22 = self.bandwidth
|
|
83
|
+
return (h11 * a, h12 * a, h22 * a)
|
|
84
|
+
if isinstance(self.bandwidth, float):
|
|
85
|
+
s = self.bandwidth * self.bandwidth * a
|
|
86
|
+
return (s, 0.0, s)
|
|
87
|
+
h11, h12, h22 = _backend.kde2d_bandwidth(xs, ys, self.bandwidth)
|
|
88
|
+
return (h11 * a, h12 * a, h22 * a)
|
|
89
|
+
|
|
90
|
+
def _normalized(self, dens: Any, xs: Any, ys: Any) -> Any:
|
|
91
|
+
if self.normalize == "pdf":
|
|
92
|
+
return dens
|
|
93
|
+
if self.normalize == "count":
|
|
94
|
+
n = sum(1 for x, y in zip(xs, ys) if x == x and y == y)
|
|
95
|
+
return _backend.scaled(dens, float(n))
|
|
96
|
+
_, _, hi = _backend.extent(dens, None)
|
|
97
|
+
return _backend.scaled(dens, 1.0 / hi if hi and math.isfinite(hi) and hi > 0 else 1.0)
|
plot_io/enums.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""String enums for enum-like settings; plain strings work everywhere."""
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
|
|
5
|
+
__all__ = ["Marker", "LineStyle", "Orientation", "Scale", "Position", "Order", "Align"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Marker(enum.StrEnum):
|
|
9
|
+
CIRCLE = "circle"
|
|
10
|
+
SQUARE = "square"
|
|
11
|
+
DIAMOND = "diamond"
|
|
12
|
+
TRIANGLE_UP = "triangle_up"
|
|
13
|
+
TRIANGLE_DOWN = "triangle_down"
|
|
14
|
+
TRIANGLE_LEFT = "triangle_left"
|
|
15
|
+
TRIANGLE_RIGHT = "triangle_right"
|
|
16
|
+
PLUS = "plus"
|
|
17
|
+
CROSS = "cross"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LineStyle(enum.StrEnum):
|
|
21
|
+
SOLID = "solid"
|
|
22
|
+
DASHED = "dashed"
|
|
23
|
+
DOTTED = "dotted"
|
|
24
|
+
DASHDOT = "dashdot"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Orientation(enum.StrEnum):
|
|
28
|
+
VERTICAL = "vertical"
|
|
29
|
+
HORIZONTAL = "horizontal"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Scale(enum.StrEnum):
|
|
33
|
+
LINEAR = "linear"
|
|
34
|
+
LOG = "log"
|
|
35
|
+
LOG2 = "log2"
|
|
36
|
+
LOG10 = "log10"
|
|
37
|
+
SQRT = "sqrt"
|
|
38
|
+
EXP = "exp"
|
|
39
|
+
CATEGORY = "category"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Position(enum.StrEnum):
|
|
43
|
+
AUTO = "auto"
|
|
44
|
+
TOP_RIGHT = "top right"
|
|
45
|
+
TOP_LEFT = "top left"
|
|
46
|
+
BOTTOM_RIGHT = "bottom right"
|
|
47
|
+
BOTTOM_LEFT = "bottom left"
|
|
48
|
+
TOP = "top"
|
|
49
|
+
BOTTOM = "bottom"
|
|
50
|
+
OUTSIDE_RIGHT = "outside right"
|
|
51
|
+
OUTSIDE_TOP = "outside top"
|
|
52
|
+
NONE = "none"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Order(enum.StrEnum):
|
|
56
|
+
AUTO = "auto"
|
|
57
|
+
ROW = "row"
|
|
58
|
+
COL = "col"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Align(enum.StrEnum):
|
|
62
|
+
AXES = "axes"
|
|
63
|
+
BORDERS = "borders"
|
plot_io/facets.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Facets: small multiples of one table, split by the values of one or two columns.
|
|
2
|
+
|
|
3
|
+
``pl.facet(data, col=…, row=…)`` makes a :class:`FacetFigure` with one plot per
|
|
4
|
+
combination of values; series methods called on it (``fig.scatter("x", "y")``) are called
|
|
5
|
+
on every panel with that panel's rows, with colors, groups and maps kept consistent across
|
|
6
|
+
panels.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from .figure import Figure
|
|
12
|
+
from .tables import column, group_keys, take
|
|
13
|
+
|
|
14
|
+
__all__ = ["facet", "FacetFigure"]
|
|
15
|
+
|
|
16
|
+
_SERIES = ("scatter", "line", "fill", "box", "heatmap", "bar", "hist", "kde", "ecdf",
|
|
17
|
+
"violin", "strip", "swarm", "area", "errorbar", "stem", "regression", "hist2d",
|
|
18
|
+
"hexbin", "contour", "pie", "hline", "vline", "axline", "hspan", "vspan", "text",
|
|
19
|
+
"annotate")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def subset(data: Any, idx: list[int]) -> Any:
|
|
23
|
+
"""The rows ``idx`` of a table."""
|
|
24
|
+
if hasattr(data, "iloc"): # pandas
|
|
25
|
+
return data.iloc[idx]
|
|
26
|
+
if isinstance(data, dict):
|
|
27
|
+
return {k: take(v, idx) for k, v in data.items()}
|
|
28
|
+
if getattr(getattr(data, "dtype", None), "names", None) is not None:
|
|
29
|
+
return data[idx] # numpy structured array
|
|
30
|
+
try:
|
|
31
|
+
return data[idx] # polars and others
|
|
32
|
+
except (TypeError, KeyError, IndexError):
|
|
33
|
+
raise ValueError("facets need a dict, a data frame or a structured array") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _levels(data: Any, name: str | None, order: Any) -> tuple[list[Any], list[list[int]]]:
|
|
37
|
+
if name is None:
|
|
38
|
+
n = len(next(iter(data.values()))) if isinstance(data, dict) else len(data)
|
|
39
|
+
return [None], [list(range(n))]
|
|
40
|
+
values, cat_order = column(data, name)
|
|
41
|
+
if hasattr(values, "tolist"):
|
|
42
|
+
values = values.tolist()
|
|
43
|
+
given = order.get(name) if isinstance(order, dict) else None
|
|
44
|
+
return group_keys(values, cat_order, given, "drop")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class FacetFigure(Figure):
|
|
48
|
+
"""A figure of facet panels (``pl.facet``)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, data: Any, col: str | None = None, row: str | None = None,
|
|
51
|
+
wrap: int | None = None, share: Any = "grid", order: Any = None,
|
|
52
|
+
title_format: str = "{name} = {value}", colorbar: Any = None,
|
|
53
|
+
**settings: Any) -> None:
|
|
54
|
+
if col is None and row is None:
|
|
55
|
+
raise ValueError("facet needs col, row or both")
|
|
56
|
+
if wrap is not None and (row is not None or col is None):
|
|
57
|
+
raise ValueError("wrap applies to col alone")
|
|
58
|
+
self.data = data
|
|
59
|
+
cols, col_rows = _levels(data, col, order)
|
|
60
|
+
rows, row_rows = _levels(data, row, order)
|
|
61
|
+
self.facet_names = (row, col)
|
|
62
|
+
panels: list[tuple[Any, Any, list[int]]] = []
|
|
63
|
+
for rk, ri in zip(rows, row_rows):
|
|
64
|
+
rset = set(ri)
|
|
65
|
+
for ck, ci in zip(cols, col_rows):
|
|
66
|
+
idx = [i for i in ci if i in rset]
|
|
67
|
+
panels.append((rk, ck, idx))
|
|
68
|
+
if wrap:
|
|
69
|
+
ncol = wrap
|
|
70
|
+
nrow = -(-len(panels) // ncol)
|
|
71
|
+
else:
|
|
72
|
+
nrow, ncol = len(rows), len(cols)
|
|
73
|
+
grid: list[list[Any]] = [[None] * ncol for _ in range(nrow)]
|
|
74
|
+
ids = []
|
|
75
|
+
for k, (rk, ck, _) in enumerate(panels):
|
|
76
|
+
r, c = divmod(k, ncol)
|
|
77
|
+
pid = f"panel{k}"
|
|
78
|
+
grid[r][c] = pid
|
|
79
|
+
ids.append(pid)
|
|
80
|
+
self._colorbar_side = colorbar
|
|
81
|
+
if colorbar == "right":
|
|
82
|
+
for cells in grid:
|
|
83
|
+
cells.append("colorbar")
|
|
84
|
+
elif colorbar not in (None, False):
|
|
85
|
+
raise ValueError("colorbar is None or 'right'")
|
|
86
|
+
super().__init__(grid=grid, share=share, **settings)
|
|
87
|
+
self.panels: dict[Any, Any] = {}
|
|
88
|
+
self._rows: dict[Any, list[int]] = {}
|
|
89
|
+
for pid, (rk, ck, idx) in zip(ids, panels):
|
|
90
|
+
key = ck if row is None else (rk if col is None else (rk, ck))
|
|
91
|
+
parts = [(n, v) for n, v in ((row, rk), (col, ck)) if n is not None]
|
|
92
|
+
title = ", ".join(title_format.format(name=n, value=v) for n, v in parts)
|
|
93
|
+
p = Figure.plot(self, pid, title=title)
|
|
94
|
+
self.panels[key] = p
|
|
95
|
+
self._rows[key] = idx
|
|
96
|
+
self._cmap: Any = None
|
|
97
|
+
self._group_levels: dict[str, list[Any]] = {}
|
|
98
|
+
self._colorbar: Any = None
|
|
99
|
+
|
|
100
|
+
def plot(self, id: Any = None, **settings: Any) -> Any:
|
|
101
|
+
"""The panel of facet key ``id`` (a value, or ``(row value, col value)``)."""
|
|
102
|
+
if id in getattr(self, "panels", {}):
|
|
103
|
+
p = self.panels[id]
|
|
104
|
+
if settings:
|
|
105
|
+
p.set(**settings)
|
|
106
|
+
return p
|
|
107
|
+
return super().plot(id, **settings)
|
|
108
|
+
|
|
109
|
+
def subsets(self) -> list[tuple[Any, Any, Any]]:
|
|
110
|
+
"""``(key, plot, rows)`` of every panel."""
|
|
111
|
+
return [(k, p, subset(self.data, self._rows[k])) for k, p in self.panels.items()]
|
|
112
|
+
|
|
113
|
+
def map(self, func: Callable[[Any, Any], Any]) -> list[Any]:
|
|
114
|
+
"""``func(plot, rows)`` for every panel; returns the results."""
|
|
115
|
+
return [func(p, rows) for _, p, rows in self.subsets()]
|
|
116
|
+
|
|
117
|
+
def _shared_map(self) -> Any:
|
|
118
|
+
if self._cmap is None:
|
|
119
|
+
from .maps import ColorMap
|
|
120
|
+
self._cmap = ColorMap(self.get("color_map"))
|
|
121
|
+
if self._colorbar_side == "right":
|
|
122
|
+
self._colorbar = self.colorbar(self._cmap, id="colorbar")
|
|
123
|
+
return self._cmap
|
|
124
|
+
|
|
125
|
+
def _call(self, method: str, args: tuple, kw: dict[str, Any]) -> list[Any]:
|
|
126
|
+
kw = dict(kw)
|
|
127
|
+
group = kw.get("group")
|
|
128
|
+
if isinstance(group, str):
|
|
129
|
+
levels = self._group_levels.get(group)
|
|
130
|
+
if levels is None:
|
|
131
|
+
values, cat = column(self.data, group)
|
|
132
|
+
if hasattr(values, "tolist"):
|
|
133
|
+
values = values.tolist()
|
|
134
|
+
levels = group_keys(values, cat, kw.get("group_order"),
|
|
135
|
+
kw.get("group_missing", "keep"))[0]
|
|
136
|
+
self._group_levels[group] = levels
|
|
137
|
+
kw.setdefault("group_order", levels)
|
|
138
|
+
colors = {lv: f"C{i}" for i, lv in enumerate(levels)}
|
|
139
|
+
colors.update(kw.get("group_colors") or {})
|
|
140
|
+
kw["group_colors"] = colors
|
|
141
|
+
mapped = any(k in kw for k in ("color", "c")) and not any(
|
|
142
|
+
k in kw for k in ("color_map", "cmap"))
|
|
143
|
+
if mapped:
|
|
144
|
+
c = kw.get("color", kw.get("c"))
|
|
145
|
+
if c == "density" or (isinstance(c, str) and _is_column(self.data, c)) \
|
|
146
|
+
or not isinstance(c, (str, tuple)):
|
|
147
|
+
kw["color_map"] = self._shared_map()
|
|
148
|
+
if "color_map" in kw and self._colorbar_side == "right" and self._colorbar is None:
|
|
149
|
+
self._cmap = kw["color_map"]
|
|
150
|
+
self._colorbar = self.colorbar(self._cmap, id="colorbar")
|
|
151
|
+
out = []
|
|
152
|
+
for _, p, rows in self.subsets():
|
|
153
|
+
out.append(getattr(p, method)(*args, data=rows, **kw))
|
|
154
|
+
if self._colorbar is not None and not self._colorbar.is_set("label") and out:
|
|
155
|
+
first = out[0][0] if isinstance(out[0], list) else out[0]
|
|
156
|
+
label = self._colorbar.__class__(first, plot=first.plot).auto_label() \
|
|
157
|
+
if hasattr(first, "color_sources") and first.color_sources() else None
|
|
158
|
+
if label is not None:
|
|
159
|
+
self._colorbar.set(label=label)
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
def __getattr__(self, name: str) -> Any:
|
|
163
|
+
if name in _SERIES:
|
|
164
|
+
def method(*args: Any, **kw: Any) -> list[Any]:
|
|
165
|
+
return self._call(name, args, kw)
|
|
166
|
+
method.__name__ = name
|
|
167
|
+
return method
|
|
168
|
+
raise AttributeError(name)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_column(data: Any, name: str) -> bool:
|
|
172
|
+
from .tables import has_column
|
|
173
|
+
return has_column(data, name)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def facet(data: Any, col: str | None = None, row: str | None = None, wrap: int | None = None,
|
|
177
|
+
share: Any = "grid", order: Any = None, title_format: str = "{name} = {value}",
|
|
178
|
+
colorbar: Any = None, **figure_settings: Any) -> FacetFigure:
|
|
179
|
+
"""Small multiples of ``data``: one plot per value of ``col`` (and ``row``); ``wrap``
|
|
180
|
+
wraps ``col`` into rows of that many panels. ``order``: ``{column: [values]}``.
|
|
181
|
+
``colorbar="right"``: one colorbar, in an extra column, for the shared color map."""
|
|
182
|
+
return FacetFigure(data, col, row, wrap, share, order, title_format, colorbar,
|
|
183
|
+
**figure_settings)
|
|
184
|
+
|
plot_io/figure.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Figures: plots (and colorbars) arranged in a grid, with sizes and output."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .settings import (THEME, Setting, Settings, boolean, color, either, literal, number,
|
|
7
|
+
one_of, table, text)
|
|
8
|
+
from .numbers import number_settings
|
|
9
|
+
from .style import check_palette
|
|
10
|
+
|
|
11
|
+
__all__ = ["Figure", "POINTS_PER_UNIT"]
|
|
12
|
+
|
|
13
|
+
POINTS_PER_UNIT = {"pt": 1.0, "in": 72.0, "cm": 72.0 / 2.54, "mm": 72.0 / 25.4}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _grid(v: Any) -> list[list[Any]]:
|
|
17
|
+
rows = [list(r) for r in v]
|
|
18
|
+
if not rows or not rows[0]:
|
|
19
|
+
raise ValueError("a grid needs at least one cell")
|
|
20
|
+
if any(len(r) != len(rows[0]) for r in rows):
|
|
21
|
+
raise ValueError("grid rows must have the same length")
|
|
22
|
+
cells: dict[Any, list[tuple[int, int]]] = {}
|
|
23
|
+
for i, r in enumerate(rows):
|
|
24
|
+
for j, k in enumerate(r):
|
|
25
|
+
if k is None or k == 0:
|
|
26
|
+
continue
|
|
27
|
+
cells.setdefault(k, []).append((i, j))
|
|
28
|
+
for k, cs in cells.items():
|
|
29
|
+
r0 = min(c[0] for c in cs)
|
|
30
|
+
r1 = max(c[0] for c in cs) + 1
|
|
31
|
+
c0 = min(c[1] for c in cs)
|
|
32
|
+
c1 = max(c[1] for c in cs) + 1
|
|
33
|
+
if len(cs) != (r1 - r0) * (c1 - c0) or any(rows[i][j] != k for i in range(r0, r1)
|
|
34
|
+
for j in range(c0, c1)):
|
|
35
|
+
raise ValueError(f"grid id {k!r} does not cover a rectangle of cells")
|
|
36
|
+
return rows
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ratios(v: Any) -> list[float]:
|
|
40
|
+
out = [number(positive=True)(x) for x in v]
|
|
41
|
+
if not out:
|
|
42
|
+
raise ValueError("expected at least one ratio")
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_UNSET = object()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _check_cmap(v: Any) -> Any:
|
|
50
|
+
from .maps import check_cmap
|
|
51
|
+
return check_cmap(v)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sharing() -> Any:
|
|
55
|
+
from . import sharing
|
|
56
|
+
return sharing
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _styles() -> Any:
|
|
60
|
+
# the module (``plot_io.styles`` the attribute is the function listing styles)
|
|
61
|
+
import importlib
|
|
62
|
+
return importlib.import_module("plot_io.styles")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class FigureTitle(Settings):
|
|
66
|
+
"""The title of a figure (``figure.title``): a band above the plots."""
|
|
67
|
+
|
|
68
|
+
SETTINGS = table(
|
|
69
|
+
text=Setting(text, doc="Title text: a string or rich content; `\\n` starts a line."),
|
|
70
|
+
font_size=Setting(number(positive=True),
|
|
71
|
+
doc="Font size (pt); default 1.4 × the base font size.", length=True),
|
|
72
|
+
font_weight=Setting(one_of("normal", "bold"), doc="`bold` or `normal`."),
|
|
73
|
+
align=Setting(one_of("center", "left", "right"), doc="Alignment."),
|
|
74
|
+
over=Setting(one_of("axes", "figure"),
|
|
75
|
+
doc="Width it is aligned and wrapped within: from the leftmost to the "
|
|
76
|
+
"rightmost axes, or the whole figure."),
|
|
77
|
+
pad=Setting(number(lo=0),
|
|
78
|
+
doc="Gap between the title and the plots (pt); default half the font "
|
|
79
|
+
"size.", length=True),
|
|
80
|
+
color=Setting(color, doc="Text color."),
|
|
81
|
+
font_family=Setting(str, inherited=True, doc="Font family."),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __init__(self, figure: "Figure") -> None:
|
|
85
|
+
self._figure = figure
|
|
86
|
+
super().__init__()
|
|
87
|
+
|
|
88
|
+
def _parent(self) -> Settings:
|
|
89
|
+
return self._figure
|
|
90
|
+
|
|
91
|
+
def __call__(self, text: Any = _UNSET, **settings: Any) -> "FigureTitle":
|
|
92
|
+
"""Set the title (``None`` removes it) and its settings."""
|
|
93
|
+
if text is not _UNSET:
|
|
94
|
+
settings["text"] = text
|
|
95
|
+
return self.set(**settings)
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"FigureTitle({self.get('text')!r})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Figure(Settings):
|
|
102
|
+
"""A figure: plots arranged in an explicit ``grid`` of ids or automatically
|
|
103
|
+
(``rows``, ``cols``, ``order``), sized by plot (``plot_width``, ``plot_height``) or as
|
|
104
|
+
a whole (``width``, ``height``)."""
|
|
105
|
+
|
|
106
|
+
SETTINGS = table(
|
|
107
|
+
grid=Setting(_grid, doc="Rows of ids; each id covers a rectangle of cells, "
|
|
108
|
+
"`0`/`None` leaves a cell empty."),
|
|
109
|
+
rows=Setting(number(lo=1, integer=True), doc="Automatic arrangement: rows."),
|
|
110
|
+
cols=Setting(number(lo=1, integer=True), doc="Automatic arrangement: columns."),
|
|
111
|
+
order=Setting(one_of("auto", "row", "col"),
|
|
112
|
+
doc="Fill order of the automatic arrangement: `auto` (row by row "
|
|
113
|
+
"with `cols`, column by column with `rows`), `row` or `col`."),
|
|
114
|
+
align=Setting(one_of("axes", "borders"),
|
|
115
|
+
doc="`axes`: axis lines line up across plots; `borders`: plot boxes "
|
|
116
|
+
"line up."),
|
|
117
|
+
widths=Setting(_ratios, doc="Column width ratios."),
|
|
118
|
+
heights=Setting(_ratios, doc="Row height ratios."),
|
|
119
|
+
plot_width=Setting(number(positive=True),
|
|
120
|
+
doc="Width of one grid cell's aligned box (axes, or whole plot "
|
|
121
|
+
"with `align=\"borders\"`), in `unit`; default 3 in."),
|
|
122
|
+
plot_height=Setting(number(positive=True),
|
|
123
|
+
doc="Height of one grid cell's aligned box, in `unit`; "
|
|
124
|
+
"default 2.25 in."),
|
|
125
|
+
width=Setting(number(positive=True), doc="Figure width in `unit` (instead of "
|
|
126
|
+
"`plot_width`)."),
|
|
127
|
+
height=Setting(number(positive=True), doc="Figure height in `unit` (instead of "
|
|
128
|
+
"`plot_height`)."),
|
|
129
|
+
unit=Setting(one_of("in", "cm", "mm", "pt"),
|
|
130
|
+
doc="Unit of the figure's sizes: `in`, `cm`, `mm` or `pt`."),
|
|
131
|
+
spacing=Setting(number(lo=0),
|
|
132
|
+
doc="Minimum gap between neighbouring plots, in `unit`; default "
|
|
133
|
+
"10 pt."),
|
|
134
|
+
margin=Setting(number(lo=0),
|
|
135
|
+
doc="Space around the figure, in `unit`; default 4 pt."),
|
|
136
|
+
background=Setting(color, doc="Figure background; unset: none in PDF, "
|
|
137
|
+
"`image_background` in images."),
|
|
138
|
+
image_background=Setting(color, doc="Background of PNG, JPEG and JPEG XL output "
|
|
139
|
+
"when `background` is unset."),
|
|
140
|
+
dpi=Setting(number(positive=True), doc="Resolution of PNG, JPEG and JPEG XL output."),
|
|
141
|
+
quality=Setting(number(lo=1, hi=100, integer=True),
|
|
142
|
+
doc="Quality (1–100), as pdf_io's: of JPEG and JPEG XL output, and of "
|
|
143
|
+
"rasterized content in PDFs (stored as JPEG). Unset: pdf_io's "
|
|
144
|
+
"defaults (JPEG 90; JPEG XL and PDF images lossless)."),
|
|
145
|
+
title_rule=Setting(color, doc="Color of a rule above the figure title, across its "
|
|
146
|
+
"width (The Economist's red bar); default none."),
|
|
147
|
+
title_rule_width=Setting(number(lo=0), doc="Thickness of the title rule (pt).",
|
|
148
|
+
length=True),
|
|
149
|
+
share_x=Setting(lambda v: _sharing().check_figure_share(v),
|
|
150
|
+
doc="Plots sharing their x axis: `False`, `True`/`\"all\"`, `\"col\"` "
|
|
151
|
+
"(same column span) or `\"row\"` (same row span)."),
|
|
152
|
+
share_y=Setting(lambda v: _sharing().check_figure_share(v),
|
|
153
|
+
doc="Plots sharing their y axis, as `share_x`."),
|
|
154
|
+
share=Setting(lambda v: _sharing().check_share(v),
|
|
155
|
+
doc="Both axes at once: as `share_x`, or `\"grid\"` (x per column, y "
|
|
156
|
+
"per row); `share_x`/`share_y` override it."),
|
|
157
|
+
label_outer=Setting(boolean,
|
|
158
|
+
doc="Tick labels and axis titles of shared axes only on the outer "
|
|
159
|
+
"plots (bottom of each column, left of each row); default "
|
|
160
|
+
"`True` for groups from the figure's settings, `False` for "
|
|
161
|
+
"plots paired with `share_x=plot`."),
|
|
162
|
+
style=Setting(lambda v: _styles().check_style(v),
|
|
163
|
+
doc="A style name (`\"ggplot2\"`, …) or a dict of settings with an "
|
|
164
|
+
"optional `\"base\"`; default: the session style (`set_style`, "
|
|
165
|
+
"`use_style`), else `\"default\"`."),
|
|
166
|
+
scaling=Setting(number(positive=True),
|
|
167
|
+
doc="Multiplies every font size, line width, marker size, tick "
|
|
168
|
+
"length and pad of the style; default 1."),
|
|
169
|
+
font_size=Setting(number(positive=True), inherited=True, doc="Base font size (pt).", length=True),
|
|
170
|
+
font_family=Setting(str, inherited=True, doc="Font family."),
|
|
171
|
+
text_color=Setting(color, inherited=True, doc="Text color."),
|
|
172
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Default line width (pt).", length=True),
|
|
173
|
+
palette=Setting(check_palette, inherited=True,
|
|
174
|
+
doc="Colors of series without one."),
|
|
175
|
+
alpha=Setting(number(0, 1), inherited=True, doc="Default opacity of series."),
|
|
176
|
+
rasterize=Setting(either(boolean, number(positive=True)), inherited=True,
|
|
177
|
+
doc="`True` or a dpi: series drawn as images inside the PDF."),
|
|
178
|
+
simplify=Setting(either(literal("auto"), boolean, number(positive=True)),
|
|
179
|
+
inherited=True,
|
|
180
|
+
doc="pdf_io's `simplify` for every series; default `\"auto\"`."),
|
|
181
|
+
data=Setting(lambda v: v, inherited=True,
|
|
182
|
+
doc="A table whose columns the series' data arguments name."),
|
|
183
|
+
color_map=Setting(lambda v: _check_cmap(v), inherited=True,
|
|
184
|
+
doc="Default color map (alias `cmap`): of heatmaps, contours and "
|
|
185
|
+
"numbers given as colors."),
|
|
186
|
+
**number_settings(),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def __init__(self, **settings: Any) -> None:
|
|
190
|
+
self._areas: dict[Any, Any] = {} # id → Plot or Colorbar, in creation order
|
|
191
|
+
self.title = FigureTitle(self)
|
|
192
|
+
self._session_style = _styles().current()
|
|
193
|
+
self._theme_key: Any = None
|
|
194
|
+
self._theme_value: dict[str, Any] = THEME
|
|
195
|
+
super().__init__(**settings)
|
|
196
|
+
|
|
197
|
+
def _theme(self) -> dict[str, Any]:
|
|
198
|
+
styles = _styles()
|
|
199
|
+
style = self._values.get("style", self._session_style)
|
|
200
|
+
scaling = self._values.get("scaling")
|
|
201
|
+
key = (id(style), scaling, styles.version())
|
|
202
|
+
if key != self._theme_key:
|
|
203
|
+
self._theme_value = styles.effective(style, scaling)
|
|
204
|
+
self._theme_key = key
|
|
205
|
+
return self._theme_value
|
|
206
|
+
|
|
207
|
+
def set(self, **settings: Any) -> "Figure":
|
|
208
|
+
super().set(**settings)
|
|
209
|
+
if self.is_set("grid") and any(self.is_set(k) for k in ("rows", "cols")):
|
|
210
|
+
raise ValueError("give either grid or rows/cols, not both")
|
|
211
|
+
if self.is_set("grid") and self.get("order") != "auto":
|
|
212
|
+
raise ValueError("order applies to the automatic arrangement, not to a grid")
|
|
213
|
+
for a, b in (("width", "plot_width"), ("height", "plot_height")):
|
|
214
|
+
if self.is_set(a) and self.is_set(b):
|
|
215
|
+
raise ValueError(f"give either {a} or {b}, not both")
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def points_per_unit(self) -> float:
|
|
220
|
+
return POINTS_PER_UNIT[self.get("unit")]
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
return f"Figure({len(self.plots)} plots)"
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def plots(self) -> list[Any]:
|
|
227
|
+
from .plot import Plot
|
|
228
|
+
return [a for a in self._areas.values() if isinstance(a, Plot)]
|
|
229
|
+
|
|
230
|
+
# -- areas -----------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _grid_ids(self) -> list[Any]:
|
|
233
|
+
ids: list[Any] = []
|
|
234
|
+
for r in self.get("grid") or []:
|
|
235
|
+
for k in r:
|
|
236
|
+
if k is not None and k != 0 and k not in ids:
|
|
237
|
+
ids.append(k)
|
|
238
|
+
return ids
|
|
239
|
+
|
|
240
|
+
def _capacity(self) -> float:
|
|
241
|
+
rows, cols = self.get("rows"), self.get("cols")
|
|
242
|
+
return rows * cols if rows and cols else math.inf
|
|
243
|
+
|
|
244
|
+
def _next_id(self) -> Any:
|
|
245
|
+
if self.is_set("grid"):
|
|
246
|
+
for k in self._grid_ids():
|
|
247
|
+
if k not in self._areas:
|
|
248
|
+
return k
|
|
249
|
+
raise ValueError("every grid id already has a plot")
|
|
250
|
+
if len(self._areas) >= self._capacity():
|
|
251
|
+
raise ValueError(f"the {self.get('rows')} × {self.get('cols')} arrangement is full")
|
|
252
|
+
i = 1
|
|
253
|
+
while i in self._areas:
|
|
254
|
+
i += 1
|
|
255
|
+
return i
|
|
256
|
+
|
|
257
|
+
def _attach(self, area: Any, id: Any) -> Any:
|
|
258
|
+
if id is None:
|
|
259
|
+
id = self._next_id()
|
|
260
|
+
elif self.is_set("grid") and id not in self._grid_ids():
|
|
261
|
+
raise ValueError(f"id {id!r} is not in the grid")
|
|
262
|
+
elif id in self._areas:
|
|
263
|
+
raise ValueError(f"id {id!r} is already taken")
|
|
264
|
+
elif not self.is_set("grid") and len(self._areas) >= self._capacity():
|
|
265
|
+
raise ValueError(f"the {self.get('rows')} × {self.get('cols')} arrangement is full")
|
|
266
|
+
self._areas[id] = area
|
|
267
|
+
return id
|
|
268
|
+
|
|
269
|
+
def plot(self, id: Any = None, **settings: Any) -> Any:
|
|
270
|
+
"""The plot with ``id``, created if needed; without ``id``, the next available
|
|
271
|
+
plot."""
|
|
272
|
+
from .plot import Plot
|
|
273
|
+
|
|
274
|
+
if id is not None and id in self._areas:
|
|
275
|
+
p = self._areas[id]
|
|
276
|
+
if not isinstance(p, Plot):
|
|
277
|
+
raise ValueError(f"id {id!r} holds a colorbar")
|
|
278
|
+
if settings:
|
|
279
|
+
p.set(**settings)
|
|
280
|
+
return p
|
|
281
|
+
return Plot(figure=self, id=id, **settings)
|
|
282
|
+
|
|
283
|
+
def colorbar(self, source: Any, id: Any = None, **settings: Any) -> Any:
|
|
284
|
+
"""A colorbar in its own area (a grid id, or the next position)."""
|
|
285
|
+
from .colorbar import Colorbar
|
|
286
|
+
|
|
287
|
+
cb = Colorbar(source, figure=self, **settings)
|
|
288
|
+
cb.id = self._attach(cb, id)
|
|
289
|
+
return cb
|
|
290
|
+
|
|
291
|
+
def size_legend(self, size_map: Any, id: Any = None, values: Any = None,
|
|
292
|
+
font_size: Any = None, title: Any = None) -> Any:
|
|
293
|
+
"""A size key of ``size_map`` in its own area (a grid id, or the next position):
|
|
294
|
+
the map's label, then sample markers with their values (``values``: which)."""
|
|
295
|
+
from .legend import SizeLegend
|
|
296
|
+
|
|
297
|
+
sl = SizeLegend(size_map, self, values, font_size, title)
|
|
298
|
+
sl.id = self._attach(sl, id)
|
|
299
|
+
return sl
|
|
300
|
+
|
|
301
|
+
# -- output ----------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def draw(self, canvas: Any, x: float = 0.0, y: float = 0.0) -> tuple[float, float]:
|
|
304
|
+
"""Draw the figure on a pdf_io canvas with its top-left corner at ``(x, y)`` (page
|
|
305
|
+
units); returns its size in points."""
|
|
306
|
+
from . import render
|
|
307
|
+
return render.draw(self, canvas, x, y)
|
|
308
|
+
|
|
309
|
+
def size(self) -> tuple[float, float]:
|
|
310
|
+
"""Width and height of the figure in points."""
|
|
311
|
+
from . import render
|
|
312
|
+
return render.size(self)
|
|
313
|
+
|
|
314
|
+
def save(self, path: Any, **options: Any) -> None:
|
|
315
|
+
"""Write a PDF, PNG, JPEG or JPEG XL (``.jxl``) by the extension of ``path``.
|
|
316
|
+
|
|
317
|
+
Options: ``dpi`` (images, default 150), ``background``, ``quality`` (JPEG, default
|
|
318
|
+
90; lossy JPEG XL, default lossless; in a PDF, of rasterized content, stored as
|
|
319
|
+
JPEG), and pdf_io ``Document`` arguments (``pdf_a``, ``color_mode``, …). Images
|
|
320
|
+
inside a PDF are PNG-like (lossless) or JPEG, never JPEG XL.
|
|
321
|
+
"""
|
|
322
|
+
from . import render
|
|
323
|
+
render.save(self, path, **options)
|
|
324
|
+
|
|
325
|
+
def to_bytes(self, format: str = "pdf", **options: Any) -> bytes:
|
|
326
|
+
"""The figure as ``"pdf"``, ``"png"``, ``"jpeg"`` or ``"jxl"`` bytes."""
|
|
327
|
+
from . import render
|
|
328
|
+
return render.to_bytes(self, format, **options)
|
|
329
|
+
|
|
330
|
+
def _repr_png_(self) -> bytes:
|
|
331
|
+
return self.to_bytes("png")
|