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/settings.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Settings: declared, validated and inherited.
|
|
2
|
+
|
|
3
|
+
Every object of the model (figure, plot, axis, series, …) declares its settings in a
|
|
4
|
+
``SETTINGS`` table of :class:`Setting`. Values are checked when they are set, and read
|
|
5
|
+
with :meth:`Settings.get`, which falls back to the parent chain (series → plot → figure)
|
|
6
|
+
for inherited settings, then to the figure's theme, then to the default in
|
|
7
|
+
:data:`plot_io.defaults.DEFAULTS`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import enum
|
|
12
|
+
import math
|
|
13
|
+
from collections.abc import Callable, Iterable
|
|
14
|
+
from typing import Any, Self
|
|
15
|
+
|
|
16
|
+
from .defaults import default_of, inherited
|
|
17
|
+
|
|
18
|
+
__all__ = ["Setting", "Settings", "THEME", "ALIASES", "route", "unknown_message", "canonical"]
|
|
19
|
+
|
|
20
|
+
#: Short names accepted for settings, on every class that has the canonical setting.
|
|
21
|
+
ALIASES = {"c": "color", "s": "size", "cmap": "color_map", "smap": "size_map"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical(name: str, table: Any) -> str:
|
|
25
|
+
"""``name``, or the setting it is an alias of when ``table`` has that setting."""
|
|
26
|
+
if name not in table:
|
|
27
|
+
target = ALIASES.get(name)
|
|
28
|
+
if target is not None and target in table:
|
|
29
|
+
return target
|
|
30
|
+
return name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_kwargs(kwargs: dict[str, Any], table: Any, what: str) -> dict[str, Any]:
|
|
34
|
+
"""``kwargs`` with aliases replaced; an alias given with its setting is an error."""
|
|
35
|
+
out: dict[str, Any] = {}
|
|
36
|
+
for name, value in kwargs.items():
|
|
37
|
+
key = canonical(name, table)
|
|
38
|
+
if key in out:
|
|
39
|
+
raise ValueError(f"{what}: {name!r} and {key!r} are the same setting; give one")
|
|
40
|
+
out[key] = value
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
#: Inherited settings at the top of the chain (the bare names of ``DEFAULTS``). A figure's
|
|
44
|
+
#: style (see :mod:`plot_io.styles`) is merged over it.
|
|
45
|
+
THEME: dict[str, Any] = inherited()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Setting:
|
|
49
|
+
"""One declared setting: ``check(value)`` validates and normalizes a value (raising
|
|
50
|
+
``ValueError``). Defaults are in :data:`plot_io.defaults.DEFAULTS`."""
|
|
51
|
+
|
|
52
|
+
__slots__ = ("name", "check", "inherited", "doc", "length")
|
|
53
|
+
|
|
54
|
+
def __init__(self, check: Callable[[Any], Any] | None = None, *,
|
|
55
|
+
inherited: bool = False, doc: str = "", length: bool = False) -> None:
|
|
56
|
+
self.name = ""
|
|
57
|
+
self.check = check
|
|
58
|
+
self.inherited = inherited
|
|
59
|
+
self.doc = doc
|
|
60
|
+
self.length = length # multiplied by a figure's `scaling`
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def table(**settings: Setting) -> dict[str, Setting]:
|
|
64
|
+
"""A ``SETTINGS`` table, with each setting's name filled in."""
|
|
65
|
+
for name, s in settings.items():
|
|
66
|
+
s.name = name
|
|
67
|
+
return settings
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def unknown_message(name: str, candidates: Iterable[str], what: str) -> str:
|
|
71
|
+
if name == "theme":
|
|
72
|
+
return ("unknown setting 'theme': use style= (a style name, or a dict of settings "
|
|
73
|
+
"with an optional \"base\")")
|
|
74
|
+
near = difflib.get_close_matches(name, list(candidates), n=1, cutoff=0.6)
|
|
75
|
+
hint = f" (did you mean {near[0]!r}?)" if near else ""
|
|
76
|
+
return f"unknown {what} setting {name!r}{hint}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Settings:
|
|
80
|
+
"""Base of every object with settings."""
|
|
81
|
+
|
|
82
|
+
SETTINGS: dict[str, Setting] = {}
|
|
83
|
+
|
|
84
|
+
def __init__(self, **settings: Any) -> None:
|
|
85
|
+
self._values: dict[str, Any] = {}
|
|
86
|
+
self.set(**settings)
|
|
87
|
+
|
|
88
|
+
def _parent(self) -> "Settings | None":
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def _theme(self) -> dict[str, Any]:
|
|
92
|
+
p = self._parent()
|
|
93
|
+
return p._theme() if p is not None else THEME
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def kind(self) -> str:
|
|
97
|
+
return type(self).__name__
|
|
98
|
+
|
|
99
|
+
def set(self, **settings: Any) -> Self:
|
|
100
|
+
"""Set settings; ``None`` goes back to the inherited or default value."""
|
|
101
|
+
settings = canonical_kwargs(settings, self.SETTINGS, self.kind)
|
|
102
|
+
checked = {}
|
|
103
|
+
for name, value in settings.items():
|
|
104
|
+
s = self.SETTINGS.get(name)
|
|
105
|
+
if s is None:
|
|
106
|
+
raise ValueError(unknown_message(name, self.SETTINGS, self.kind))
|
|
107
|
+
if value is not None and s.check is not None:
|
|
108
|
+
try:
|
|
109
|
+
value = s.check(value)
|
|
110
|
+
except (TypeError, ValueError) as e:
|
|
111
|
+
raise ValueError(f"{self.kind} setting {name!r}: {e}") from None
|
|
112
|
+
checked[name] = value
|
|
113
|
+
for name, value in checked.items():
|
|
114
|
+
if value is None:
|
|
115
|
+
self._values.pop(name, None)
|
|
116
|
+
else:
|
|
117
|
+
self._values[name] = value
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def _theme_keys(self, name: str) -> tuple[str, ...]:
|
|
121
|
+
"""Theme keys of a setting, most specific first."""
|
|
122
|
+
return (f"{self.kind}.{name}",)
|
|
123
|
+
|
|
124
|
+
def is_set(self, name: str) -> bool:
|
|
125
|
+
return name in self._values
|
|
126
|
+
|
|
127
|
+
def get(self, name: str) -> Any:
|
|
128
|
+
"""The resolved value of a setting."""
|
|
129
|
+
if name in self._values:
|
|
130
|
+
return self._values[name]
|
|
131
|
+
s = self.SETTINGS[name]
|
|
132
|
+
theme = self._theme()
|
|
133
|
+
for key in self._theme_keys(name):
|
|
134
|
+
if key in theme:
|
|
135
|
+
return theme[key]
|
|
136
|
+
if s.inherited:
|
|
137
|
+
p = self._parent()
|
|
138
|
+
while p is not None:
|
|
139
|
+
if name in p.SETTINGS:
|
|
140
|
+
return p.get(name)
|
|
141
|
+
p = p._parent()
|
|
142
|
+
return theme.get(name)
|
|
143
|
+
return default_of(self, name)
|
|
144
|
+
|
|
145
|
+
def settings(self) -> dict[str, Any]:
|
|
146
|
+
"""The settings set explicitly on this object."""
|
|
147
|
+
return dict(self._values)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def route(kwargs: dict[str, Any], levels: list[type[Settings]],
|
|
151
|
+
extra: dict[int, Iterable[str]] | None = None) -> list[dict[str, Any]]:
|
|
152
|
+
"""Split ``kwargs`` among ``levels`` (lowest first): each name goes to the lowest
|
|
153
|
+
level declaring it (``extra`` adds names a level accepts besides its settings)."""
|
|
154
|
+
out: list[dict[str, Any]] = [{} for _ in levels]
|
|
155
|
+
for name, value in kwargs.items():
|
|
156
|
+
for i, cls in enumerate(levels):
|
|
157
|
+
key = canonical(name, cls.SETTINGS)
|
|
158
|
+
if key in cls.SETTINGS or (extra and name in extra.get(i, ())):
|
|
159
|
+
if key in out[i]:
|
|
160
|
+
raise ValueError(f"{cls.__name__}: {name!r} and {key!r} are the same "
|
|
161
|
+
f"setting; give one")
|
|
162
|
+
out[i][key] = value
|
|
163
|
+
break
|
|
164
|
+
else:
|
|
165
|
+
names = set().union(*(cls.SETTINGS for cls in levels))
|
|
166
|
+
raise ValueError(unknown_message(name, names, levels[0].__name__))
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# Validators
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def number(lo: float | None = None, hi: float | None = None, positive: bool = False,
|
|
175
|
+
integer: bool = False) -> Callable[[Any], float]:
|
|
176
|
+
def check(v):
|
|
177
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
178
|
+
raise ValueError(f"expected a number, got {v!r}")
|
|
179
|
+
v = int(v) if integer else float(v)
|
|
180
|
+
if integer and not float(v).is_integer():
|
|
181
|
+
raise ValueError(f"expected an integer, got {v!r}")
|
|
182
|
+
if not math.isfinite(v):
|
|
183
|
+
raise ValueError("expected a finite number")
|
|
184
|
+
if positive and not v > 0:
|
|
185
|
+
raise ValueError(f"expected a positive number, got {v!r}")
|
|
186
|
+
if lo is not None and v < lo:
|
|
187
|
+
raise ValueError(f"expected at least {lo}, got {v!r}")
|
|
188
|
+
if hi is not None and v > hi:
|
|
189
|
+
raise ValueError(f"expected at most {hi}, got {v!r}")
|
|
190
|
+
return v
|
|
191
|
+
return check
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def boolean(v: Any) -> bool:
|
|
195
|
+
if not isinstance(v, bool):
|
|
196
|
+
raise ValueError(f"expected True or False, got {v!r}")
|
|
197
|
+
return v
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def one_of(*choices: str, aliases: dict[str, str] | None = None) -> Callable[[Any], str]:
|
|
201
|
+
def check(v):
|
|
202
|
+
if isinstance(v, enum.Enum):
|
|
203
|
+
v = v.value
|
|
204
|
+
if isinstance(v, str):
|
|
205
|
+
key = v.strip().lower().replace("-", "_")
|
|
206
|
+
key = (aliases or {}).get(key, key)
|
|
207
|
+
if key in choices:
|
|
208
|
+
return key
|
|
209
|
+
raise ValueError(f"expected one of {', '.join(map(repr, choices))}, got {v!r}")
|
|
210
|
+
return check
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def text(v: Any) -> Any:
|
|
214
|
+
"""A string or rich content (a list of strings and spans)."""
|
|
215
|
+
if isinstance(v, str):
|
|
216
|
+
return v
|
|
217
|
+
if isinstance(v, (list, tuple)):
|
|
218
|
+
return list(v)
|
|
219
|
+
if hasattr(v, "text"):
|
|
220
|
+
return [v]
|
|
221
|
+
return str(v)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def anything(v: Any) -> Any:
|
|
225
|
+
return v
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def either(*checks: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
|
229
|
+
def check(v):
|
|
230
|
+
errors = []
|
|
231
|
+
for c in checks:
|
|
232
|
+
try:
|
|
233
|
+
return c(v)
|
|
234
|
+
except (TypeError, ValueError) as e:
|
|
235
|
+
errors.append(str(e))
|
|
236
|
+
raise ValueError("; ".join(errors))
|
|
237
|
+
return check
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def literal(*values: Any) -> Callable[[Any], Any]:
|
|
241
|
+
def check(v):
|
|
242
|
+
for w in values:
|
|
243
|
+
if v == w and type(v) is type(w):
|
|
244
|
+
return v
|
|
245
|
+
raise ValueError(f"expected one of {', '.join(map(repr, values))}, got {v!r}")
|
|
246
|
+
return check
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def color(v: Any) -> Any:
|
|
250
|
+
"""A color pdf_io accepts, or a palette reference ``"C0"``, ``"C1"``, …"""
|
|
251
|
+
from pdf_io.colors import to_rgba
|
|
252
|
+
|
|
253
|
+
if isinstance(v, str) and len(v) >= 2 and v[0] in "Cc" and v[1:].isdigit():
|
|
254
|
+
return "C" + v[1:]
|
|
255
|
+
try:
|
|
256
|
+
rgba = to_rgba(v)
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
rgba = None
|
|
259
|
+
if rgba is None and not (isinstance(v, str) and v.strip().lower() in ("none", "transparent")):
|
|
260
|
+
raise ValueError(f"invalid color {v!r}")
|
|
261
|
+
return v
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def pair(check: Callable[[Any], Any]) -> Callable[[Any], tuple[Any, Any]]:
|
|
265
|
+
def c(v):
|
|
266
|
+
if not isinstance(v, (tuple, list)) or len(v) != 2:
|
|
267
|
+
raise ValueError(f"expected a pair, got {v!r}")
|
|
268
|
+
return (check(v[0]), check(v[1]))
|
|
269
|
+
return c
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def dpi_or_bool(v: Any) -> Any:
|
|
273
|
+
if isinstance(v, bool):
|
|
274
|
+
return v
|
|
275
|
+
return number(positive=True)(v)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def simplify_value(v: Any) -> Any:
|
|
279
|
+
if v == "auto":
|
|
280
|
+
return "auto"
|
|
281
|
+
return dpi_or_bool(v)
|
plot_io/shapes.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Many rectangles or polygons in one call (pdf_io's ``draw_rects`` and ``fill_polygons``)."""
|
|
2
|
+
|
|
3
|
+
from array import array
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
__all__ = ["draw_rects", "fill_polygons"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def draw_rects(canvas: Any, x: Any, y: Any, w: Any, h: Any, fill: Any = None,
|
|
10
|
+
stroke: Any = None, line_width: Any = None, simplify: Any = None) -> None:
|
|
11
|
+
"""Rectangles with top-left corners ``(x, y)`` and sizes ``(w, h)`` (page units);
|
|
12
|
+
``fill``, ``stroke`` and ``line_width``: one value or one per rectangle."""
|
|
13
|
+
if not len(x):
|
|
14
|
+
return
|
|
15
|
+
canvas.draw_rects(x, y, w, h, fill=fill, stroke=stroke, line_width=line_width,
|
|
16
|
+
simplify=simplify)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fill_polygons(canvas: Any, polygons: list[list[tuple[Any, Any]]], fill: Any = None,
|
|
20
|
+
stroke: Any = None, line_width: Any = None, fill_rule: str = "nonzero",
|
|
21
|
+
simplify: Any = None) -> None:
|
|
22
|
+
"""Polygons, each a list of rings ``(xs, ys)`` filled together (holes work);
|
|
23
|
+
``fill``, ``stroke`` and ``line_width``: one value or one per polygon."""
|
|
24
|
+
if not polygons:
|
|
25
|
+
return
|
|
26
|
+
xs, ys = array("d"), array("d")
|
|
27
|
+
ring_starts, path_starts = array("q"), array("q")
|
|
28
|
+
for poly in polygons:
|
|
29
|
+
path_starts.append(len(ring_starts))
|
|
30
|
+
for rx, ry in poly:
|
|
31
|
+
ring_starts.append(len(xs))
|
|
32
|
+
xs.extend(rx)
|
|
33
|
+
ys.extend(ry)
|
|
34
|
+
ring_starts.append(len(xs))
|
|
35
|
+
path_starts.append(len(ring_starts) - 1)
|
|
36
|
+
canvas.fill_polygons((xs, ys, ring_starts, path_starts), fill=fill, stroke=stroke,
|
|
37
|
+
line_width=line_width, fill_rule=fill_rule, simplify=simplify)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fill_ring_sets(canvas: Any, ring_sets: list[tuple[Any, Any, Any]], fill: Any,
|
|
41
|
+
simplify: Any = None) -> None:
|
|
42
|
+
"""Paths given as pdf_io ring arrays ``(xs, ys, ring_starts)`` (one path each), one fill
|
|
43
|
+
per path."""
|
|
44
|
+
xs, ys = array("d"), array("d")
|
|
45
|
+
ring_starts, path_starts = array("q"), array("q")
|
|
46
|
+
for rx, ry, starts in ring_sets:
|
|
47
|
+
path_starts.append(len(ring_starts))
|
|
48
|
+
base = len(xs)
|
|
49
|
+
ring_starts.extend(base + s for s in starts[:-1])
|
|
50
|
+
xs.extend(rx)
|
|
51
|
+
ys.extend(ry)
|
|
52
|
+
if not len(ring_starts):
|
|
53
|
+
return
|
|
54
|
+
ring_starts.append(len(xs))
|
|
55
|
+
path_starts.append(len(ring_starts) - 1)
|
|
56
|
+
canvas.fill_polygons((xs, ys, ring_starts, path_starts), fill=fill, fill_rule="nonzero",
|
|
57
|
+
simplify=simplify)
|
plot_io/sharing.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Shared axes: plots whose x (or y) axes form a group have one scale, one set of limits,
|
|
2
|
+
the same ticks and category order, and (``label_outer``) tick labels and titles only on
|
|
3
|
+
the outer plots of each column (x) or row (y).
|
|
4
|
+
|
|
5
|
+
Groups come from the figure's ``share_x``/``share_y``/``share`` (all plots, plots of a
|
|
6
|
+
column span, plots of a row span) and from plots' own ``share_x=other`` (chained).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["check_figure_share", "check_share", "groups", "apply"]
|
|
12
|
+
|
|
13
|
+
_MODES = ("all", "col", "row")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_figure_share(v: Any) -> Any:
|
|
17
|
+
"""``False``, ``True``/``"all"``, ``"col"`` or ``"row"``."""
|
|
18
|
+
if v is False or v is True:
|
|
19
|
+
return "all" if v else False
|
|
20
|
+
if isinstance(v, str) and v.strip().lower() in _MODES:
|
|
21
|
+
return v.strip().lower()
|
|
22
|
+
raise ValueError(f"expected False, True, 'all', 'col' or 'row', got {v!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_share(v: Any) -> Any:
|
|
26
|
+
"""The figure's ``share``: as ``share_x`` or ``"grid"`` (x per column, y per row)."""
|
|
27
|
+
if isinstance(v, str) and v.strip().lower() == "grid":
|
|
28
|
+
return "grid"
|
|
29
|
+
return check_figure_share(v)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def figure_modes(fig: Any) -> dict[str, Any]:
|
|
33
|
+
"""The figure's sharing mode per axis role."""
|
|
34
|
+
share = fig.get("share")
|
|
35
|
+
out: dict[str, Any] = {"x": False, "y": False}
|
|
36
|
+
if share == "grid":
|
|
37
|
+
out = {"x": "col", "y": "row"}
|
|
38
|
+
elif share:
|
|
39
|
+
out = {"x": share, "y": share}
|
|
40
|
+
for role in ("x", "y"):
|
|
41
|
+
if fig.is_set(f"share_{role}"):
|
|
42
|
+
out[role] = fig.get(f"share_{role}")
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _Union:
|
|
47
|
+
def __init__(self, items: list[Any]) -> None:
|
|
48
|
+
self.parent = {id(p): id(p) for p in items}
|
|
49
|
+
|
|
50
|
+
def find(self, k: int) -> int:
|
|
51
|
+
while self.parent[k] != k:
|
|
52
|
+
self.parent[k] = self.parent[self.parent[k]]
|
|
53
|
+
k = self.parent[k]
|
|
54
|
+
return k
|
|
55
|
+
|
|
56
|
+
def join(self, a: Any, b: Any) -> None:
|
|
57
|
+
ra, rb = self.find(id(a)), self.find(id(b))
|
|
58
|
+
if ra != rb:
|
|
59
|
+
self.parent[rb] = ra
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def groups(fig: Any, areas: list[Any], role: str) -> list[tuple[list[Any], bool]]:
|
|
63
|
+
"""Groups of areas sharing axis ``role`` (two or more), each with whether it comes from
|
|
64
|
+
the figure's setting (outer labels by default)."""
|
|
65
|
+
from .plot import Plot
|
|
66
|
+
|
|
67
|
+
plots = [a for a in areas if isinstance(a.obj, Plot) and a.obj.get("axes")]
|
|
68
|
+
if not plots:
|
|
69
|
+
return []
|
|
70
|
+
mode = figure_modes(fig)[role]
|
|
71
|
+
u = _Union(plots)
|
|
72
|
+
own = {id(a): a.obj.own_share(role) for a in plots}
|
|
73
|
+
figure_made: set[int] = set()
|
|
74
|
+
if mode:
|
|
75
|
+
free = [a for a in plots if own[id(a)] is None]
|
|
76
|
+
keys: dict[int, Any]
|
|
77
|
+
if mode == "all":
|
|
78
|
+
keys = {id(a): 0 for a in free}
|
|
79
|
+
elif mode == "col":
|
|
80
|
+
keys = {id(a): (a.c0, a.c1) for a in free}
|
|
81
|
+
else:
|
|
82
|
+
keys = {id(a): (a.r0, a.r1) for a in free}
|
|
83
|
+
first: dict[Any, Any] = {}
|
|
84
|
+
for a in free:
|
|
85
|
+
k = keys[id(a)]
|
|
86
|
+
if k in first:
|
|
87
|
+
u.join(first[k], a)
|
|
88
|
+
else:
|
|
89
|
+
first[k] = a
|
|
90
|
+
figure_made = {id(a) for a in free}
|
|
91
|
+
by_plot = {id(a.obj): a for a in plots}
|
|
92
|
+
for a in plots:
|
|
93
|
+
other = own[id(a)]
|
|
94
|
+
if other is not None:
|
|
95
|
+
b = by_plot.get(id(other))
|
|
96
|
+
if b is None:
|
|
97
|
+
raise ValueError(f"{a.obj!r} shares its {role} axis with {other!r}, which is "
|
|
98
|
+
f"not a plot of the same figure")
|
|
99
|
+
u.join(b, a)
|
|
100
|
+
out: dict[int, list[Any]] = {}
|
|
101
|
+
for a in plots:
|
|
102
|
+
out.setdefault(u.find(id(a)), []).append(a)
|
|
103
|
+
result = []
|
|
104
|
+
for members in out.values():
|
|
105
|
+
if len(members) > 1:
|
|
106
|
+
result.append((members, all(id(m) in figure_made for m in members)))
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _leader(members: list[Any], role: str) -> Any:
|
|
111
|
+
axes = [getattr(m.obj, f"{role}_axis") for m in members]
|
|
112
|
+
limited = [a for a in axes if a.get("limits") != "auto"]
|
|
113
|
+
specs = {repr(a.get("limits")) for a in limited}
|
|
114
|
+
if len(specs) > 1:
|
|
115
|
+
raise ValueError(f"plots sharing their {role} axis have different limits: "
|
|
116
|
+
f"{', '.join(sorted(specs))}")
|
|
117
|
+
scales = {a.get("scale") for a in axes if a.is_set("scale")}
|
|
118
|
+
if len(scales) > 1:
|
|
119
|
+
raise ValueError(f"plots sharing their {role} axis have different scales: "
|
|
120
|
+
f"{', '.join(sorted(map(str, scales)))}")
|
|
121
|
+
for pick in (limited, [a for a in axes if a.is_set("scale")], axes):
|
|
122
|
+
if pick:
|
|
123
|
+
return members[axes.index(pick[0])]
|
|
124
|
+
return members[0]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def apply(fig: Any, areas: list[Any]) -> None:
|
|
128
|
+
"""Resolve every group: one scale and set of limits from all members' series (category
|
|
129
|
+
order in plot order), ticks shared, and outer labels."""
|
|
130
|
+
label_outer = fig.get("label_outer")
|
|
131
|
+
for role in ("x", "y"):
|
|
132
|
+
for members, from_figure in groups(fig, areas, role):
|
|
133
|
+
lead = _leader(members, role)
|
|
134
|
+
items = []
|
|
135
|
+
for m in members:
|
|
136
|
+
items.extend(m.state.role_items[role])
|
|
137
|
+
lstate = getattr(lead.state, role)
|
|
138
|
+
lstate.resolve(items, lead.state.padded)
|
|
139
|
+
for m in members:
|
|
140
|
+
st = getattr(m.state, role)
|
|
141
|
+
st.shared = lstate if m is not lead else None
|
|
142
|
+
if m is not lead:
|
|
143
|
+
st.copy_limits(lstate)
|
|
144
|
+
outer = label_outer if label_outer is not None else from_figure
|
|
145
|
+
if not outer:
|
|
146
|
+
continue
|
|
147
|
+
for m in members:
|
|
148
|
+
getattr(m.state, role).hide_labels = not _is_outer(m, members, role)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_outer(a: Any, members: list[Any], role: str) -> bool:
|
|
152
|
+
if role == "x":
|
|
153
|
+
same = [m for m in members if (m.c0, m.c1) == (a.c0, a.c1)]
|
|
154
|
+
if getattr(a.state, "x").on_far_side():
|
|
155
|
+
return a.r0 == min(m.r0 for m in same)
|
|
156
|
+
return a.r1 == max(m.r1 for m in same)
|
|
157
|
+
same = [m for m in members if (m.r0, m.r1) == (a.r0, a.r1)]
|
|
158
|
+
if getattr(a.state, "y").on_far_side():
|
|
159
|
+
return a.c1 == max(m.c1 for m in same)
|
|
160
|
+
return a.c0 == min(m.c0 for m in same)
|
plot_io/slots.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Category slots: categorical series of one dodge group share each category's slot.
|
|
2
|
+
|
|
3
|
+
Box, violin, bar, strip and swarm series place one item per category. The series of a
|
|
4
|
+
dodge group (``Series.DODGE_GROUP``) with the same orientation that have a category split
|
|
5
|
+
its slot (``width``) evenly, in call order (stacked series count as one); ``dodge=False``
|
|
6
|
+
overlays a series on the whole slot. ``gap`` is the fraction of each part left empty when several series share a slot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["slots"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _token(s: Any) -> Any:
|
|
15
|
+
"""Stacked series share one part of a slot, and so do split violins."""
|
|
16
|
+
if "stack" in s.SETTINGS and s.get("stack"):
|
|
17
|
+
return "stack"
|
|
18
|
+
if "split" in s.SETTINGS and s.get("split"):
|
|
19
|
+
return "split"
|
|
20
|
+
return s
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slots(series: Any, ctx: Any) -> list[tuple[float, float]]:
|
|
24
|
+
"""``(center, width)`` of each of ``series.cats``, in category units."""
|
|
25
|
+
width = series.get("width")
|
|
26
|
+
positions = getattr(series, "positions", None)
|
|
27
|
+
if positions is not None:
|
|
28
|
+
return [(p, width) for p in positions]
|
|
29
|
+
horizontal = series._horizontal()
|
|
30
|
+
state = ctx.y if horizontal else ctx.x
|
|
31
|
+
group = series.DODGE_GROUP
|
|
32
|
+
peers = [s for s in ctx.plot.series if s.DODGE_GROUP == group
|
|
33
|
+
and getattr(s, "positions", None) is None and s._horizontal() == horizontal]
|
|
34
|
+
gap = series.get("gap") if "gap" in series.SETTINGS else 0.0
|
|
35
|
+
out = []
|
|
36
|
+
for k in series.cats:
|
|
37
|
+
i = state.categories.index(k)
|
|
38
|
+
if not series.get("dodge"):
|
|
39
|
+
out.append((float(i), width))
|
|
40
|
+
continue
|
|
41
|
+
members: list[Any] = []
|
|
42
|
+
for s in peers:
|
|
43
|
+
if not s.get("dodge") or k not in s.cats:
|
|
44
|
+
continue
|
|
45
|
+
token = _token(s)
|
|
46
|
+
if token not in members:
|
|
47
|
+
members.append(token)
|
|
48
|
+
m = len(members)
|
|
49
|
+
j = members.index(_token(series))
|
|
50
|
+
share = width / m
|
|
51
|
+
out.append((i - width / 2 + (j + 0.5) * share, share * (1.0 - gap) if m > 1 else share))
|
|
52
|
+
return out
|
plot_io/stacking.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Stacking: series with ``stack=True`` sit on the previous stacked series of their group.
|
|
2
|
+
|
|
3
|
+
A stackable series (``Series.STACK_GROUP``) gives its layers as ``(keys, values)``
|
|
4
|
+
(``stack_layers()``): a key per value (a category or an x value). Layers stack in call
|
|
5
|
+
order within the plot, per key; positive and negative values stack separately. With
|
|
6
|
+
``stack="normalize"`` on any series of the group, each key's values are divided by the
|
|
7
|
+
key's total (of absolute values).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
__all__ = ["stacked", "check_stack"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_stack(v: Any) -> Any:
|
|
17
|
+
if v == "normalize":
|
|
18
|
+
return "normalize"
|
|
19
|
+
if isinstance(v, bool):
|
|
20
|
+
return v
|
|
21
|
+
raise ValueError(f"expected True, False or 'normalize', got {v!r}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _group(s: Any) -> tuple[Any, bool]:
|
|
25
|
+
return s.STACK_GROUP, s._horizontal()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def stacked(series: Any) -> list[tuple[list[float], list[float]]]:
|
|
29
|
+
"""``(bottoms, tops)`` of each layer of ``series`` (which has ``stack`` set)."""
|
|
30
|
+
group = _group(series)
|
|
31
|
+
members = [s for s in series.plot.series if s.STACK_GROUP is not None
|
|
32
|
+
and s.get("stack") and _group(s) == group]
|
|
33
|
+
normalize = any(s.get("stack") == "normalize" for s in members)
|
|
34
|
+
totals: dict[Any, float] = {}
|
|
35
|
+
if normalize:
|
|
36
|
+
for s in members:
|
|
37
|
+
for keys, vals in s.stack_layers():
|
|
38
|
+
for k, v in zip(keys, vals):
|
|
39
|
+
if math.isfinite(v):
|
|
40
|
+
totals[k] = totals.get(k, 0.0) + abs(v)
|
|
41
|
+
pos: dict[Any, float] = {}
|
|
42
|
+
neg: dict[Any, float] = {}
|
|
43
|
+
for s in members:
|
|
44
|
+
out = []
|
|
45
|
+
for keys, vals in s.stack_layers():
|
|
46
|
+
bottoms, tops = [], []
|
|
47
|
+
for k, v in zip(keys, vals):
|
|
48
|
+
if not math.isfinite(v):
|
|
49
|
+
bottoms.append(math.nan)
|
|
50
|
+
tops.append(math.nan)
|
|
51
|
+
continue
|
|
52
|
+
if normalize:
|
|
53
|
+
t = totals.get(k, 0.0)
|
|
54
|
+
v = v / t if t else 0.0
|
|
55
|
+
acc = pos if v >= 0 else neg
|
|
56
|
+
b = acc.get(k, 0.0)
|
|
57
|
+
acc[k] = b + v
|
|
58
|
+
bottoms.append(b)
|
|
59
|
+
tops.append(b + v)
|
|
60
|
+
out.append((bottoms, tops))
|
|
61
|
+
if s is series:
|
|
62
|
+
return out
|
|
63
|
+
return []
|