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/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]