plot3 0.4.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.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/compose.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Several plots in one figure (patchwork-style): ``p1 | p2``, ``p1 / p2``.
|
|
2
|
+
|
|
3
|
+
(p1 | p2) / p3 + plot_annotation(title="Results", tag_levels="A")
|
|
4
|
+
|
|
5
|
+
``|`` puts plots side by side, ``/`` stacks them, and the two nest.
|
|
6
|
+
``plot_layout(widths=[2, 1])`` sets relative sizes. ``plot_annotation``
|
|
7
|
+
adds a figure title, subtitle, and caption, and tags the panels A, B, C
|
|
8
|
+
(or ``"a"``, ``"1"``, ``"I"``). A plot's own ``labs(tag=)`` wins.
|
|
9
|
+
|
|
10
|
+
Every plot keeps its own scales and legend. Static export draws them all
|
|
11
|
+
with one type size, taken from the whole figure, so panels match.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import html as _htmlesc
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class plot_layout:
|
|
23
|
+
"""``widths`` / ``heights``: relative sizes of the side-by-side or stacked
|
|
24
|
+
parts. ``height`` is the whole figure's height in the notebook viewer
|
|
25
|
+
(pixels, or a CSS length such as ``"60vh"``)."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, widths=None, heights=None, height=None):
|
|
28
|
+
self.widths = None if widths is None else [float(w) for w in widths]
|
|
29
|
+
self.heights = None if heights is None else [float(h) for h in heights]
|
|
30
|
+
self.height = None if height is None else (height if isinstance(height, str) else f"{int(height)}px")
|
|
31
|
+
for sizes in (self.widths, self.heights):
|
|
32
|
+
if sizes is not None and any(v <= 0 for v in sizes):
|
|
33
|
+
raise ValueError("plot_layout() sizes must be positive")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class plot_annotation:
|
|
37
|
+
"""Title, subtitle, and caption for the whole figure, and panel tags.
|
|
38
|
+
|
|
39
|
+
``tag_levels`` is ``"A"`` (A, B, C), ``"a"``, ``"1"``, or ``"I"``
|
|
40
|
+
(roman). ``tag_prefix`` / ``tag_suffix`` wrap each tag, e.g. ``"("``
|
|
41
|
+
and ``")"``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, title=None, subtitle=None, caption=None,
|
|
45
|
+
tag_levels=None, tag_prefix="", tag_suffix=""):
|
|
46
|
+
if tag_levels not in {None, "A", "a", "1", "I", "i"}:
|
|
47
|
+
raise ValueError('tag_levels is "A", "a", "1", "I", or "i"')
|
|
48
|
+
self.title = title
|
|
49
|
+
self.subtitle = subtitle
|
|
50
|
+
self.caption = caption
|
|
51
|
+
self.tag_levels = tag_levels
|
|
52
|
+
self.tag_prefix = str(tag_prefix)
|
|
53
|
+
self.tag_suffix = str(tag_suffix)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _roman(n: int) -> str:
|
|
57
|
+
parts = [(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")]
|
|
58
|
+
out = ""
|
|
59
|
+
for value, symbol in parts:
|
|
60
|
+
while n >= value:
|
|
61
|
+
out += symbol
|
|
62
|
+
n -= value
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _tag(index: int, levels: str) -> str:
|
|
67
|
+
if levels == "A":
|
|
68
|
+
return chr(ord("A") + index % 26)
|
|
69
|
+
if levels == "a":
|
|
70
|
+
return chr(ord("a") + index % 26)
|
|
71
|
+
if levels == "1":
|
|
72
|
+
return str(index + 1)
|
|
73
|
+
if levels == "I":
|
|
74
|
+
return _roman(index + 1)
|
|
75
|
+
return _roman(index + 1).lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Composition:
|
|
79
|
+
"""A row (``|``) or column (``/``) of plots and nested compositions."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, direction: str, items: list, sizes=None, annotation=None):
|
|
82
|
+
self.direction = direction # "row" | "col"
|
|
83
|
+
self.items = list(items)
|
|
84
|
+
self.sizes = sizes
|
|
85
|
+
self.annotation = annotation
|
|
86
|
+
# None: tall enough for its rows of plots (see figure_height).
|
|
87
|
+
self.height = None
|
|
88
|
+
|
|
89
|
+
def rows(self) -> int:
|
|
90
|
+
"""How many plots stand above one another."""
|
|
91
|
+
counts = [item.rows() if isinstance(item, Composition) else 1 for item in self.items]
|
|
92
|
+
return sum(counts) if self.direction == "col" else max(counts)
|
|
93
|
+
|
|
94
|
+
def figure_height(self) -> str:
|
|
95
|
+
"""The viewer's height: as set, or 400 px per row of plots (480 px at
|
|
96
|
+
least, a single plot's height), plus room for a figure title."""
|
|
97
|
+
if self.height:
|
|
98
|
+
return self.height
|
|
99
|
+
extra = 40 if getattr(self.annotation, "title", None) else 0
|
|
100
|
+
return f"{max(480, 400 * self.rows()) + extra}px"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def theme_family(self):
|
|
104
|
+
return getattr(self.leaves()[0], "theme_family", None)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def theme_base_size(self):
|
|
108
|
+
return getattr(self.leaves()[0], "theme_base_size", None)
|
|
109
|
+
|
|
110
|
+
# ── grammar ──────────────────────────────────────────────────────────
|
|
111
|
+
def _join(self, other, direction):
|
|
112
|
+
_check_plot(other)
|
|
113
|
+
if self.direction == direction and self.sizes is None and self.annotation is None:
|
|
114
|
+
return Composition(direction, self.items + [other])
|
|
115
|
+
return Composition(direction, [self, other])
|
|
116
|
+
|
|
117
|
+
def __or__(self, other):
|
|
118
|
+
return self._join(other, "row")
|
|
119
|
+
|
|
120
|
+
def __truediv__(self, other):
|
|
121
|
+
return self._join(other, "col")
|
|
122
|
+
|
|
123
|
+
def __ror__(self, other):
|
|
124
|
+
return Composition("row", [other, self])
|
|
125
|
+
|
|
126
|
+
def __rtruediv__(self, other):
|
|
127
|
+
return Composition("col", [other, self])
|
|
128
|
+
|
|
129
|
+
def __add__(self, other):
|
|
130
|
+
out = copy.copy(self)
|
|
131
|
+
if isinstance(other, plot_layout):
|
|
132
|
+
sizes = other.widths if self.direction == "row" else other.heights
|
|
133
|
+
if sizes is not None and len(sizes) != len(self.items):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"plot_layout() got {len(sizes)} sizes for {len(self.items)} parts"
|
|
136
|
+
)
|
|
137
|
+
out.sizes = sizes if sizes is not None else self.sizes
|
|
138
|
+
if other.height is not None:
|
|
139
|
+
out.height = other.height
|
|
140
|
+
return out
|
|
141
|
+
if isinstance(other, plot_annotation):
|
|
142
|
+
out.annotation = other
|
|
143
|
+
return out
|
|
144
|
+
raise TypeError(
|
|
145
|
+
"add plot_layout() or plot_annotation() to a figure of several plots"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# ── layout ───────────────────────────────────────────────────────────
|
|
149
|
+
def leaves(self) -> list:
|
|
150
|
+
out = []
|
|
151
|
+
for item in self.items:
|
|
152
|
+
out.extend(item.leaves() if isinstance(item, Composition) else [item])
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
def tagged(self) -> "Composition":
|
|
156
|
+
"""A copy whose plots carry their tags (the figure's tag_levels)."""
|
|
157
|
+
levels = getattr(self.annotation, "tag_levels", None)
|
|
158
|
+
if not levels:
|
|
159
|
+
return self
|
|
160
|
+
prefix = self.annotation.tag_prefix
|
|
161
|
+
suffix = self.annotation.tag_suffix
|
|
162
|
+
counter = iter(range(10_000))
|
|
163
|
+
|
|
164
|
+
def walk(node):
|
|
165
|
+
if isinstance(node, Composition):
|
|
166
|
+
clone = copy.copy(node)
|
|
167
|
+
clone.items = [walk(item) for item in node.items]
|
|
168
|
+
return clone
|
|
169
|
+
index = next(counter)
|
|
170
|
+
if node.labs.get("tag"):
|
|
171
|
+
return node
|
|
172
|
+
plot = copy.copy(node)
|
|
173
|
+
plot.labs = dict(node.labs, tag=f"{prefix}{_tag(index, levels)}{suffix}")
|
|
174
|
+
return plot
|
|
175
|
+
|
|
176
|
+
return walk(self)
|
|
177
|
+
|
|
178
|
+
def rects(self, x: float, y: float, w: float, h: float, gap: float = 10.0) -> list:
|
|
179
|
+
"""``[(plot, x, y, w, h)]`` for every plot inside this rectangle."""
|
|
180
|
+
n = len(self.items)
|
|
181
|
+
weights = self.sizes or [1.0] * n
|
|
182
|
+
total = float(sum(weights))
|
|
183
|
+
out = []
|
|
184
|
+
along = w if self.direction == "row" else h
|
|
185
|
+
usable = along - gap * (n - 1)
|
|
186
|
+
at = x if self.direction == "row" else y
|
|
187
|
+
for item, weight in zip(self.items, weights):
|
|
188
|
+
size = usable * weight / total
|
|
189
|
+
if self.direction == "row":
|
|
190
|
+
box = (at, y, size, h)
|
|
191
|
+
else:
|
|
192
|
+
box = (x, at, w, size)
|
|
193
|
+
at += size + gap
|
|
194
|
+
if isinstance(item, Composition):
|
|
195
|
+
out.extend(item.rects(*box, gap=gap))
|
|
196
|
+
else:
|
|
197
|
+
out.append((item, *box))
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
# ── output ───────────────────────────────────────────────────────────
|
|
201
|
+
def html(self) -> str:
|
|
202
|
+
"""A standalone page: the plots in nested flex boxes."""
|
|
203
|
+
fig = self.tagged()
|
|
204
|
+
esc = _htmlesc.escape
|
|
205
|
+
theme = _first_theme(fig)
|
|
206
|
+
|
|
207
|
+
def node_html(node) -> str:
|
|
208
|
+
if isinstance(node, Composition):
|
|
209
|
+
flow = "row" if node.direction == "row" else "column"
|
|
210
|
+
weights = node.sizes or [1.0] * len(node.items)
|
|
211
|
+
parts = "".join(
|
|
212
|
+
f"<div class='part' style='flex:{weight} 1 0'>{node_html(item)}</div>"
|
|
213
|
+
for item, weight in zip(node.items, weights)
|
|
214
|
+
)
|
|
215
|
+
return f"<div class='box' style='flex-direction:{flow}'>{parts}</div>"
|
|
216
|
+
doc = node.html()
|
|
217
|
+
return f"<iframe srcdoc=\"{esc(doc, quote=True)}\" title=\"panel\"></iframe>"
|
|
218
|
+
|
|
219
|
+
note = fig.annotation
|
|
220
|
+
title = esc(str(getattr(note, "title", "") or ""))
|
|
221
|
+
subtitle = esc(str(getattr(note, "subtitle", "") or ""))
|
|
222
|
+
caption = esc(str(getattr(note, "caption", "") or ""))
|
|
223
|
+
return f"""<!doctype html>
|
|
224
|
+
<html><head><meta charset="utf-8"><style>
|
|
225
|
+
html,body{{margin:0;height:100%;background:{theme["surface"]};color:{theme["ink"]};
|
|
226
|
+
font:12px system-ui,-apple-system,"Segoe UI",sans-serif}}
|
|
227
|
+
#wrap{{box-sizing:border-box;height:100%;padding:8px;display:flex;flex-direction:column;gap:4px}}
|
|
228
|
+
#ctitle{{font-size:15px;font-weight:600;margin:0 4px}}
|
|
229
|
+
#csub{{font-size:12px;color:{theme["ink2"]};margin:0 4px 4px}}
|
|
230
|
+
#ccap{{font-size:11px;color:{theme["muted"]};text-align:right;margin:0 4px}}
|
|
231
|
+
.box{{display:flex;gap:10px;flex:1;min-height:0;min-width:0;height:100%}}
|
|
232
|
+
.part{{display:flex;min-height:0;min-width:0}}
|
|
233
|
+
iframe{{flex:1;width:100%;height:100%;border:0;background:{theme["surface"]}}}
|
|
234
|
+
</style></head><body><div id="wrap">
|
|
235
|
+
{f"<div id='ctitle'>{title}</div>" if title else ""}{f"<div id='csub'>{subtitle}</div>" if subtitle else ""}
|
|
236
|
+
{node_html(fig)}
|
|
237
|
+
{f"<div id='ccap'>{caption}</div>" if caption else ""}
|
|
238
|
+
</div></body></html>"""
|
|
239
|
+
|
|
240
|
+
def save(self, path, *, width=None, height=None, units="px", dpi=None,
|
|
241
|
+
family=None, fontsize=None, notes=False) -> str:
|
|
242
|
+
suffix = Path(str(path)).suffix.lower()
|
|
243
|
+
if suffix in {".png", ".svg", ".pdf"}:
|
|
244
|
+
from plot3.static import save_static
|
|
245
|
+
|
|
246
|
+
return save_static(
|
|
247
|
+
self, path, width=width, height=height, units=units, dpi=dpi,
|
|
248
|
+
family=family, fontsize=fontsize, notes=notes,
|
|
249
|
+
)
|
|
250
|
+
doc = self.html()
|
|
251
|
+
Path(str(path)).write_text(doc, encoding="utf-8")
|
|
252
|
+
print(f"plot3: saved {path} ({len(doc) // 1024} KB)")
|
|
253
|
+
return str(path)
|
|
254
|
+
|
|
255
|
+
def _iframe(self) -> str:
|
|
256
|
+
theme = _first_theme(self)
|
|
257
|
+
return (
|
|
258
|
+
f'<div class="plot3-fig"><iframe srcdoc="{_htmlesc.escape(self.html(), quote=True)}" '
|
|
259
|
+
'sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-downloads" '
|
|
260
|
+
'allow="fullscreen; clipboard-write" '
|
|
261
|
+
f'style="width:100%;height:{self.figure_height()};border:0;border-radius:6px;'
|
|
262
|
+
f'background:{theme["surface"]}" title="plot3 figure"></iframe></div>'
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _repr_html_(self) -> str:
|
|
266
|
+
return self._iframe()
|
|
267
|
+
|
|
268
|
+
def _ipython_display_(self) -> None:
|
|
269
|
+
from IPython.display import HTML, display
|
|
270
|
+
|
|
271
|
+
display(HTML(self._iframe()))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _check_plot(item: Any) -> None:
|
|
275
|
+
from plot3.ggplot import ggplot
|
|
276
|
+
|
|
277
|
+
if not isinstance(item, (ggplot, Composition)):
|
|
278
|
+
raise TypeError("| and / combine plots: ggplot(...) | ggplot(...)")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _first_theme(fig) -> dict:
|
|
282
|
+
from plot3.themes import _THEMES
|
|
283
|
+
|
|
284
|
+
leaf = fig.leaves()[0]
|
|
285
|
+
return _THEMES[leaf.theme_name]
|