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/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")