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/ggplot.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""ggplot figure object, display, and ggsave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
|
|
7
|
+
from plot3 import scaling as _scaling
|
|
8
|
+
import html as _htmlesc
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from plot3.geoms import (
|
|
15
|
+
_Geom,
|
|
16
|
+
aes,
|
|
17
|
+
area,
|
|
18
|
+
coord_3d,
|
|
19
|
+
coord_equal,
|
|
20
|
+
coord_cartesian,
|
|
21
|
+
coord_flip,
|
|
22
|
+
coord_polar,
|
|
23
|
+
derivative,
|
|
24
|
+
facet_grid,
|
|
25
|
+
facet_wrap,
|
|
26
|
+
expand_limits,
|
|
27
|
+
guides,
|
|
28
|
+
labs,
|
|
29
|
+
scale_colour_continuous,
|
|
30
|
+
scale_x_log10,
|
|
31
|
+
scale_y_log10,
|
|
32
|
+
stat_density_3d,
|
|
33
|
+
slider,
|
|
34
|
+
tangent,
|
|
35
|
+
transition_states,
|
|
36
|
+
transition_time,
|
|
37
|
+
_Theme,
|
|
38
|
+
_ThemePatch,
|
|
39
|
+
)
|
|
40
|
+
from plot3.table import as_table, detect_backend
|
|
41
|
+
from plot3.themes import _THEMES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_flag(name: str) -> bool | None:
|
|
45
|
+
raw = os.environ.get(name)
|
|
46
|
+
if raw is None or raw == "":
|
|
47
|
+
return None
|
|
48
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _in_solveit() -> bool:
|
|
52
|
+
try:
|
|
53
|
+
import importlib.util
|
|
54
|
+
|
|
55
|
+
return importlib.util.find_spec("dialoghelper") is not None
|
|
56
|
+
except Exception:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _in_vscode_notebook() -> bool:
|
|
61
|
+
"""True when the kernel was launched from VS Code / Cursor.
|
|
62
|
+
|
|
63
|
+
The kernel process often does **not** inherit VSCODE_* env vars, so also
|
|
64
|
+
walk parent process names (macOS/Linux).
|
|
65
|
+
"""
|
|
66
|
+
keys = (
|
|
67
|
+
"VSCODE_PID",
|
|
68
|
+
"VSCODE_CWD",
|
|
69
|
+
"VSCODE_NLS_CONFIG",
|
|
70
|
+
"VSCODE_ESM_ENTRYPOINT",
|
|
71
|
+
"VSCODE_HANDLES_UNCAUGHT_ERRORS",
|
|
72
|
+
"CURSOR_TRACE_ID",
|
|
73
|
+
)
|
|
74
|
+
if any(k in os.environ for k in keys):
|
|
75
|
+
return True
|
|
76
|
+
# Connection file / argv hints used by the Jupyter extension
|
|
77
|
+
joined = " ".join(sys.argv).lower()
|
|
78
|
+
if "vscode" in joined or "cursor" in joined:
|
|
79
|
+
return True
|
|
80
|
+
try:
|
|
81
|
+
import subprocess
|
|
82
|
+
|
|
83
|
+
pid = os.getpid()
|
|
84
|
+
for _ in range(6):
|
|
85
|
+
out = subprocess.check_output(
|
|
86
|
+
["ps", "-p", str(pid), "-o", "ppid=,comm="],
|
|
87
|
+
text=True,
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
).strip()
|
|
90
|
+
if not out:
|
|
91
|
+
break
|
|
92
|
+
parts = out.split(None, 1)
|
|
93
|
+
if len(parts) < 2:
|
|
94
|
+
break
|
|
95
|
+
ppid_s, comm = parts[0], parts[1].lower()
|
|
96
|
+
if any(
|
|
97
|
+
tag in comm
|
|
98
|
+
for tag in (
|
|
99
|
+
"visual studio code",
|
|
100
|
+
"code helper",
|
|
101
|
+
"cursor",
|
|
102
|
+
"electron",
|
|
103
|
+
"code ",
|
|
104
|
+
)
|
|
105
|
+
) or comm in {"code", "cursor"}:
|
|
106
|
+
return True
|
|
107
|
+
try:
|
|
108
|
+
pid = int(ppid_s)
|
|
109
|
+
except ValueError:
|
|
110
|
+
break
|
|
111
|
+
if pid <= 1:
|
|
112
|
+
break
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _display_mode() -> str:
|
|
119
|
+
return (os.environ.get("PLOT3_DISPLAY") or "").strip().lower()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _skip_blank_hint() -> bool:
|
|
123
|
+
"""No fallback hint in SolveIt, or when the user already chose an iframe."""
|
|
124
|
+
if _in_solveit():
|
|
125
|
+
return True
|
|
126
|
+
return _display_mode() in ("iframe", "inline", "notebook")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _prefer_external_browser() -> bool:
|
|
130
|
+
"""VS Code notebook webviews block CDN ES modules inside iframes.
|
|
131
|
+
|
|
132
|
+
SolveIt / full browsers render the srcdoc iframe fine. Force either mode
|
|
133
|
+
with PLOT3_DISPLAY=browser|iframe.
|
|
134
|
+
"""
|
|
135
|
+
mode = _display_mode()
|
|
136
|
+
if mode in ("browser", "external", "file"):
|
|
137
|
+
return True
|
|
138
|
+
if mode in ("iframe", "inline", "notebook"):
|
|
139
|
+
return False
|
|
140
|
+
# SolveIt first: dialoghelper present ⇒ always iframe (even if a VS Code
|
|
141
|
+
# env var leaked into the kernel process).
|
|
142
|
+
if _in_solveit():
|
|
143
|
+
return False
|
|
144
|
+
return _in_vscode_notebook()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ggplot:
|
|
148
|
+
"""A plot3 figure, optionally deferred until data arrives via ``>>``."""
|
|
149
|
+
|
|
150
|
+
# Prefer ggplot.__rrshift__ over NumPy bitshift when piping an ndarray:
|
|
151
|
+
# ``points >> ggplot(aes(x=0, y=1)) + geom_point()``.
|
|
152
|
+
__array_priority__ = 10000
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
data=None,
|
|
157
|
+
mapping: aes | None = None,
|
|
158
|
+
*,
|
|
159
|
+
height="480px",
|
|
160
|
+
quantize=True,
|
|
161
|
+
compress=True,
|
|
162
|
+
hide=None,
|
|
163
|
+
):
|
|
164
|
+
# ``ggplot(aes(...))`` is the R-shaped, pipeable form. ``aes`` is a
|
|
165
|
+
# dict subclass, so detect it before treating arbitrary mappings as
|
|
166
|
+
# dataframe constructor input.
|
|
167
|
+
if isinstance(data, aes) and mapping is None:
|
|
168
|
+
data, mapping = None, data
|
|
169
|
+
self.data = self._as_table(data) if data is not None else None
|
|
170
|
+
self.backend = self._detect_backend(self.data) if self.data is not None else None
|
|
171
|
+
self.mapping = mapping or aes()
|
|
172
|
+
self.layers: list[_Geom] = []
|
|
173
|
+
self.labs: dict = {}
|
|
174
|
+
self.theme_name = "dark"
|
|
175
|
+
# False until a theme is added: saved files then use theme_bw.
|
|
176
|
+
self.theme_explicit = False
|
|
177
|
+
# Font for ggsave. None keeps the size ladder and Helvetica/Arial.
|
|
178
|
+
self.theme_family: str | None = None
|
|
179
|
+
self.theme_base_size: float | None = None
|
|
180
|
+
# None means static export puts the legend outside on the right.
|
|
181
|
+
self.legend_position = None
|
|
182
|
+
self.theme_options: dict = {}
|
|
183
|
+
self.cscale: scale_colour_continuous | None = None
|
|
184
|
+
self.facet: facet_wrap | None = None
|
|
185
|
+
self.coord: coord_3d | coord_equal | coord_polar | None = None
|
|
186
|
+
# (layer index, area | tangent | derivative) attached to a geom_function.
|
|
187
|
+
self._addons: list = []
|
|
188
|
+
self.stat_density_3d: stat_density_3d | None = None
|
|
189
|
+
self.scale_x: scale_x_log10 | None = None
|
|
190
|
+
self.scale_y: scale_y_log10 | None = None
|
|
191
|
+
# scale_x_continuous / xlim / scale_colour_manual / scale_shape_manual …
|
|
192
|
+
self.xscale = None
|
|
193
|
+
self.yscale = None
|
|
194
|
+
self.colour_scale = None
|
|
195
|
+
self.shape_scale = None
|
|
196
|
+
self.linetype_scale = None
|
|
197
|
+
self.transition: transition_time | transition_states | None = None
|
|
198
|
+
self.slider: slider | None = None
|
|
199
|
+
self.height = height if isinstance(height, str) else f"{int(height)}px"
|
|
200
|
+
self.quantize = bool(quantize)
|
|
201
|
+
self.compress = bool(compress)
|
|
202
|
+
self.hide = hide # None -> module default (autohide())
|
|
203
|
+
# Frozen PlotPayload (Phase C): display without source data.
|
|
204
|
+
self._payload: dict | None = None
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _as_table(data):
|
|
208
|
+
"""Keep pandas / polars / tidy3 frames; wrap plain constructors as pandas."""
|
|
209
|
+
return as_table(data)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _detect_backend(data) -> str:
|
|
213
|
+
return detect_backend(data)
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def from_payload(
|
|
217
|
+
cls,
|
|
218
|
+
payload: dict,
|
|
219
|
+
*,
|
|
220
|
+
height: str | int = "480px",
|
|
221
|
+
hide: bool | None = None,
|
|
222
|
+
) -> "ggplot":
|
|
223
|
+
"""Rebuild a displayable figure from a PlotPayload (no DataFrame).
|
|
224
|
+
|
|
225
|
+
The returned object can call :meth:`html`, :meth:`show`, and
|
|
226
|
+
:meth:`save` using only the payload. Grammar operators (``+``, ``>>``)
|
|
227
|
+
invalidate the payload and require live data again.
|
|
228
|
+
"""
|
|
229
|
+
from plot3.payload import validate_payload
|
|
230
|
+
|
|
231
|
+
payload = validate_payload(payload)
|
|
232
|
+
g = cls(data=None, height=height, hide=hide)
|
|
233
|
+
g._payload = payload
|
|
234
|
+
# Best-effort labels for iframe title; full labs live in the payload.
|
|
235
|
+
labs_map = payload["spec"].get("labs") or {}
|
|
236
|
+
g.labs = {
|
|
237
|
+
k: v
|
|
238
|
+
for k, v in labs_map.items()
|
|
239
|
+
if v not in (None, "") and k in ("title", "x", "y", "z", "color")
|
|
240
|
+
}
|
|
241
|
+
return g
|
|
242
|
+
|
|
243
|
+
def freeze(self) -> "ggplot":
|
|
244
|
+
"""Encode stats into a PlotPayload and drop the source table.
|
|
245
|
+
|
|
246
|
+
After :meth:`freeze`, display methods use only the payload (suitable
|
|
247
|
+
for shipping across a local/remote boundary). Adding layers or piping
|
|
248
|
+
new data clears the freeze.
|
|
249
|
+
"""
|
|
250
|
+
if getattr(self, "facet", None) is not None:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"freeze() does not support facet_wrap(); use html() for faceted figures"
|
|
253
|
+
)
|
|
254
|
+
if self._payload is None:
|
|
255
|
+
if self.data is None and not self.layers:
|
|
256
|
+
raise ValueError("freeze() needs data and at least one layer")
|
|
257
|
+
self._payload = self.to_payload()
|
|
258
|
+
self.data = None
|
|
259
|
+
self.backend = None
|
|
260
|
+
return self
|
|
261
|
+
|
|
262
|
+
def __rrshift__(self, data):
|
|
263
|
+
"""Bind data to a deferred ``ggplot(aes(...))`` template."""
|
|
264
|
+
if self.data is not None:
|
|
265
|
+
raise TypeError("cannot pipe data into a ggplot that already has data")
|
|
266
|
+
if self._payload is not None:
|
|
267
|
+
raise TypeError(
|
|
268
|
+
"cannot pipe data into a payload-backed ggplot; "
|
|
269
|
+
"build a new ggplot(...) template instead"
|
|
270
|
+
)
|
|
271
|
+
g = copy.copy(self)
|
|
272
|
+
g.layers = list(self.layers)
|
|
273
|
+
g.labs = dict(self.labs)
|
|
274
|
+
g.facet = self.facet
|
|
275
|
+
g.coord = self.coord
|
|
276
|
+
g.stat_density_3d = self.stat_density_3d
|
|
277
|
+
g._addons = list(getattr(self, "_addons", ()) or ())
|
|
278
|
+
g._payload = None
|
|
279
|
+
g.data = self._as_table(data)
|
|
280
|
+
g.backend = self._detect_backend(g.data)
|
|
281
|
+
return g
|
|
282
|
+
|
|
283
|
+
def __or__(self, other):
|
|
284
|
+
"""``p1 | p2``: side by side in one figure."""
|
|
285
|
+
from plot3.compose import Composition, _check_plot
|
|
286
|
+
|
|
287
|
+
_check_plot(other)
|
|
288
|
+
return Composition("row", [self, other])
|
|
289
|
+
|
|
290
|
+
def __truediv__(self, other):
|
|
291
|
+
"""``p1 / p2``: one above the other."""
|
|
292
|
+
from plot3.compose import Composition, _check_plot
|
|
293
|
+
|
|
294
|
+
_check_plot(other)
|
|
295
|
+
return Composition("col", [self, other])
|
|
296
|
+
|
|
297
|
+
def __add__(self, other):
|
|
298
|
+
g = copy.copy(self)
|
|
299
|
+
g.layers = list(self.layers)
|
|
300
|
+
g.labs = dict(self.labs)
|
|
301
|
+
g.facet = self.facet
|
|
302
|
+
g.coord = self.coord
|
|
303
|
+
g.stat_density_3d = self.stat_density_3d
|
|
304
|
+
g._addons = list(getattr(self, "_addons", ()) or ())
|
|
305
|
+
# Grammar changes invalidate a frozen payload.
|
|
306
|
+
g._payload = None
|
|
307
|
+
if isinstance(other, _Geom):
|
|
308
|
+
g.layers.append(other)
|
|
309
|
+
elif isinstance(other, (area, tangent, derivative)):
|
|
310
|
+
if not g.layers or getattr(g.layers[-1], "kind", None) != "function":
|
|
311
|
+
example = {
|
|
312
|
+
"area": "area(0, 2)",
|
|
313
|
+
"tangent": "tangent(at=1)",
|
|
314
|
+
"derivative": "derivative()",
|
|
315
|
+
}[type(other).__name__]
|
|
316
|
+
raise TypeError(
|
|
317
|
+
f"{type(other).__name__}() follows geom_function(). "
|
|
318
|
+
f'For example ggplot() + geom_function("y = x^2") + {example}'
|
|
319
|
+
)
|
|
320
|
+
g._addons.append((len(g.layers) - 1, other))
|
|
321
|
+
elif isinstance(other, labs):
|
|
322
|
+
g.labs.update(other)
|
|
323
|
+
elif isinstance(other, expand_limits):
|
|
324
|
+
merged = dict(getattr(g, "expand", None) or {})
|
|
325
|
+
for axis, values in other.values.items():
|
|
326
|
+
merged[axis] = list(merged.get(axis, [])) + list(values)
|
|
327
|
+
g.expand = merged
|
|
328
|
+
elif isinstance(other, guides):
|
|
329
|
+
g.guides = {**(getattr(g, "guides", None) or {}), **other.hidden}
|
|
330
|
+
g.guide_options = {**(getattr(g, "guide_options", None) or {}), **other.options}
|
|
331
|
+
elif isinstance(other, _Theme):
|
|
332
|
+
g.theme_name = other.name
|
|
333
|
+
g.theme_explicit = True
|
|
334
|
+
g.theme_family = other.base_family
|
|
335
|
+
g.theme_base_size = other.base_size
|
|
336
|
+
elif isinstance(other, _ThemePatch):
|
|
337
|
+
if other.legend_position is not None:
|
|
338
|
+
g.legend_position = other.legend_position
|
|
339
|
+
options = dict(getattr(other, "options", {}) or {})
|
|
340
|
+
if "base_size" in options:
|
|
341
|
+
g.theme_base_size = options.pop("base_size")
|
|
342
|
+
if "base_family" in options:
|
|
343
|
+
g.theme_family = options.pop("base_family")
|
|
344
|
+
before = dict(getattr(g, "theme_options", {}) or {})
|
|
345
|
+
if "tokens" in options:
|
|
346
|
+
# Colours from element_*(): a later theme() adds to earlier ones.
|
|
347
|
+
options["tokens"] = {**before.get("tokens", {}), **options["tokens"]}
|
|
348
|
+
g.theme_options = {**before, **options}
|
|
349
|
+
elif isinstance(other, scale_colour_continuous):
|
|
350
|
+
g.cscale = other
|
|
351
|
+
elif isinstance(other, (facet_wrap, facet_grid)):
|
|
352
|
+
g.facet = other
|
|
353
|
+
elif isinstance(other, (coord_3d, coord_equal, coord_polar, coord_flip, coord_cartesian)):
|
|
354
|
+
g.coord = other
|
|
355
|
+
elif isinstance(other, stat_density_3d):
|
|
356
|
+
g.stat_density_3d = other
|
|
357
|
+
elif isinstance(other, scale_x_log10):
|
|
358
|
+
g.scale_x = other
|
|
359
|
+
elif isinstance(other, scale_y_log10):
|
|
360
|
+
g.scale_y = other
|
|
361
|
+
elif isinstance(other, _scaling._Lims):
|
|
362
|
+
for part in other:
|
|
363
|
+
g = g + part
|
|
364
|
+
elif isinstance(other, _scaling.PositionScale):
|
|
365
|
+
if other.axis == "x":
|
|
366
|
+
g.xscale = other
|
|
367
|
+
g.scale_x = scale_x_log10() if other.trans == "log10" else (
|
|
368
|
+
None if other.kind == "continuous" else g.scale_x
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
g.yscale = other
|
|
372
|
+
g.scale_y = scale_y_log10() if other.trans == "log10" else (
|
|
373
|
+
None if other.kind == "continuous" else g.scale_y
|
|
374
|
+
)
|
|
375
|
+
elif isinstance(other, _scaling.ColourScale):
|
|
376
|
+
g.colour_scale = other
|
|
377
|
+
elif isinstance(other, _scaling.SizeScale):
|
|
378
|
+
g.size_scale = other
|
|
379
|
+
elif isinstance(other, _scaling.AlphaScale):
|
|
380
|
+
g.alpha_scale = other
|
|
381
|
+
elif isinstance(other, _scaling.KeyScale):
|
|
382
|
+
if other.aesthetic == "shape":
|
|
383
|
+
g.shape_scale = other
|
|
384
|
+
else:
|
|
385
|
+
g.linetype_scale = other
|
|
386
|
+
elif isinstance(other, slider):
|
|
387
|
+
if self.transition is not None:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"slider() cannot be combined with transition_time() "
|
|
390
|
+
"or transition_states()"
|
|
391
|
+
)
|
|
392
|
+
g.slider = other
|
|
393
|
+
elif isinstance(other, (transition_time, transition_states)):
|
|
394
|
+
if self.slider is not None:
|
|
395
|
+
raise ValueError(
|
|
396
|
+
"slider() cannot be combined with transition_time() "
|
|
397
|
+
"or transition_states()"
|
|
398
|
+
)
|
|
399
|
+
g.transition = other
|
|
400
|
+
elif isinstance(other, aes):
|
|
401
|
+
m = aes()
|
|
402
|
+
m.update(self.mapping)
|
|
403
|
+
m.update(other)
|
|
404
|
+
g.mapping = m
|
|
405
|
+
else:
|
|
406
|
+
raise TypeError(f"cannot add {type(other).__name__!r} to ggplot")
|
|
407
|
+
return g
|
|
408
|
+
|
|
409
|
+
def _maybe_hide_from_ai(self) -> None:
|
|
410
|
+
# SolveIt: big viewer HTML must not enter LLM context.
|
|
411
|
+
if self.hide if self.hide is not None else AUTOHIDE:
|
|
412
|
+
try:
|
|
413
|
+
from plot3.jupyter import hide_caller_from_ai
|
|
414
|
+
|
|
415
|
+
hide_caller_from_ai()
|
|
416
|
+
except Exception:
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
def _repr_html_(self) -> str:
|
|
420
|
+
# Used by hosts that only understand HTML reprs (and by tests).
|
|
421
|
+
# Prefer ``display(fig)`` / ``fig.show()`` so VS Code can open a browser.
|
|
422
|
+
self._maybe_hide_from_ai()
|
|
423
|
+
return self._iframe()
|
|
424
|
+
|
|
425
|
+
def _repr_mimebundle_(self, include=None, exclude=None):
|
|
426
|
+
"""HTML plus optional PlotPayload mime for CRAFT host handoff.
|
|
427
|
+
|
|
428
|
+
Always includes ``text/html`` (iframe). When the figure can produce a
|
|
429
|
+
single-panel PlotPayload, also includes
|
|
430
|
+
``application/vnd.plot3.v1+json`` so a host can render without
|
|
431
|
+
re-encoding. Faceted figures only provide HTML.
|
|
432
|
+
"""
|
|
433
|
+
from plot3.remote import MIME_PLOT3
|
|
434
|
+
|
|
435
|
+
self._maybe_hide_from_ai()
|
|
436
|
+
bundle: dict = {"text/html": self._iframe()}
|
|
437
|
+
try:
|
|
438
|
+
if self.facet is None and (
|
|
439
|
+
self._payload is not None or self.data is not None or self.layers
|
|
440
|
+
):
|
|
441
|
+
bundle[MIME_PLOT3] = self.to_payload()
|
|
442
|
+
except Exception:
|
|
443
|
+
# Facet / incomplete figure: HTML only.
|
|
444
|
+
pass
|
|
445
|
+
if include is not None:
|
|
446
|
+
bundle = {k: v for k, v in bundle.items() if k in include}
|
|
447
|
+
if exclude is not None:
|
|
448
|
+
bundle = {k: v for k, v in bundle.items() if k not in exclude}
|
|
449
|
+
return bundle
|
|
450
|
+
|
|
451
|
+
def _ipython_display_(self) -> None:
|
|
452
|
+
"""IPython entry point — browser in VS Code, iframe in SolveIt.
|
|
453
|
+
|
|
454
|
+
On a CRAFT remote kernel (``PLOT3_REMOTE=1`` etc.), publish a mimebundle
|
|
455
|
+
that includes the PlotPayload so a smart host can take the compact
|
|
456
|
+
form; HTML remains the universal fallback.
|
|
457
|
+
"""
|
|
458
|
+
self._maybe_hide_from_ai()
|
|
459
|
+
try:
|
|
460
|
+
from plot3.remote import is_remote_kernel
|
|
461
|
+
|
|
462
|
+
if is_remote_kernel():
|
|
463
|
+
from IPython.display import publish_display_data
|
|
464
|
+
|
|
465
|
+
data = self._repr_mimebundle_()
|
|
466
|
+
# Prefer publish so both HTML and payload are available.
|
|
467
|
+
publish_display_data(data)
|
|
468
|
+
return
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
self.show(browser=_prefer_external_browser())
|
|
472
|
+
|
|
473
|
+
def to_payload(self) -> dict:
|
|
474
|
+
"""Serialize this figure to a PlotPayload (stats + encoded blobs).
|
|
475
|
+
|
|
476
|
+
The payload is JSON-friendly (spec dict + base64 blobs) and does not
|
|
477
|
+
retain the source DataFrame. Suitable for shipping from a remote/GPU
|
|
478
|
+
kernel to a local viewer via :func:`plot3.payload.render_payload`.
|
|
479
|
+
|
|
480
|
+
Returns a cached payload when the figure was built with
|
|
481
|
+
:meth:`from_payload` or :meth:`freeze`. Faceted figures are not
|
|
482
|
+
supported here; use :meth:`html` / ``build_doc``.
|
|
483
|
+
"""
|
|
484
|
+
if self._payload is not None:
|
|
485
|
+
return self._payload
|
|
486
|
+
from plot3.payload import build_payload
|
|
487
|
+
|
|
488
|
+
return build_payload(self)
|
|
489
|
+
|
|
490
|
+
def html(self) -> str:
|
|
491
|
+
"""The full standalone document (what the iframe srcdoc carries)."""
|
|
492
|
+
if self._payload is not None:
|
|
493
|
+
from plot3.payload import render_payload
|
|
494
|
+
|
|
495
|
+
return render_payload(self._payload, log=True)
|
|
496
|
+
from plot3.build import build_doc
|
|
497
|
+
|
|
498
|
+
return build_doc(self)
|
|
499
|
+
|
|
500
|
+
def save(
|
|
501
|
+
self,
|
|
502
|
+
path: str | Path,
|
|
503
|
+
*,
|
|
504
|
+
width=None,
|
|
505
|
+
height=None,
|
|
506
|
+
units: str = "px",
|
|
507
|
+
dpi: float | None = None,
|
|
508
|
+
family: str | None = None,
|
|
509
|
+
fontsize: float | None = None,
|
|
510
|
+
notes: bool = False,
|
|
511
|
+
) -> str:
|
|
512
|
+
"""Write this figure.
|
|
513
|
+
|
|
514
|
+
``.png``, ``.svg``, and ``.pdf`` write a static image. Any other
|
|
515
|
+
suffix writes the standalone HTML viewer. ``width`` and ``height``
|
|
516
|
+
are pixels unless ``units`` is ``"in"``, ``"cm"``, or ``"mm"``.
|
|
517
|
+
``dpi`` is the PNG resolution for a physical size (default 300).
|
|
518
|
+
PDF needs ``plot3[export]`` and the Cairo C library. ``.svg``
|
|
519
|
+
needs neither.
|
|
520
|
+
"""
|
|
521
|
+
suffix = Path(path).suffix.lower()
|
|
522
|
+
if suffix in {".png", ".svg", ".pdf"}:
|
|
523
|
+
from plot3.static import save_static
|
|
524
|
+
|
|
525
|
+
return save_static(
|
|
526
|
+
self,
|
|
527
|
+
path,
|
|
528
|
+
width=width,
|
|
529
|
+
height=height,
|
|
530
|
+
units=units,
|
|
531
|
+
dpi=dpi,
|
|
532
|
+
family=family,
|
|
533
|
+
fontsize=fontsize,
|
|
534
|
+
notes=notes,
|
|
535
|
+
)
|
|
536
|
+
path = str(path)
|
|
537
|
+
doc = self.html()
|
|
538
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
539
|
+
f.write(doc)
|
|
540
|
+
print(f"plot3: saved {path} ({len(doc) // 1024} KB)")
|
|
541
|
+
return path
|
|
542
|
+
|
|
543
|
+
def _write_preview(
|
|
544
|
+
self, path: str | Path | None = None, doc: str | None = None
|
|
545
|
+
) -> Path:
|
|
546
|
+
if path is None:
|
|
547
|
+
out_dir = Path.cwd() / ".plot3_preview"
|
|
548
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
path = out_dir / "latest.html"
|
|
550
|
+
else:
|
|
551
|
+
path = Path(path)
|
|
552
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
if doc is None:
|
|
554
|
+
doc = self.html()
|
|
555
|
+
path.write_text(doc, encoding="utf-8")
|
|
556
|
+
return path.resolve()
|
|
557
|
+
|
|
558
|
+
def show(self, *, browser: bool | None = None, path: str | Path | None = None):
|
|
559
|
+
"""Display the figure.
|
|
560
|
+
|
|
561
|
+
Parameters
|
|
562
|
+
----------
|
|
563
|
+
browser:
|
|
564
|
+
``True`` write a standalone HTML file and open it in the system
|
|
565
|
+
browser (reliable in VS Code). ``False`` embed an iframe in the
|
|
566
|
+
notebook output (SolveIt / classic Jupyter). ``None`` auto-detect.
|
|
567
|
+
path:
|
|
568
|
+
Optional HTML path when using the browser path (default
|
|
569
|
+
``./.plot3_preview/latest.html``).
|
|
570
|
+
"""
|
|
571
|
+
if browser is None:
|
|
572
|
+
browser = _prefer_external_browser()
|
|
573
|
+
doc = self.html()
|
|
574
|
+
|
|
575
|
+
if browser:
|
|
576
|
+
out = self._write_preview(path, doc)
|
|
577
|
+
uri = out.as_uri()
|
|
578
|
+
open_browser = _env_flag("PLOT3_NO_BROWSER") is not True
|
|
579
|
+
try:
|
|
580
|
+
from IPython.display import HTML, display
|
|
581
|
+
|
|
582
|
+
display(
|
|
583
|
+
HTML(
|
|
584
|
+
"<div style='font:13px system-ui,sans-serif;padding:8px 10px;"
|
|
585
|
+
"border-radius:8px;background:#1e293b;color:#e2e8f0'>"
|
|
586
|
+
"<b>plot3</b>: opened in your system browser (VS Code notebook "
|
|
587
|
+
"webviews block the WebGL/CDN viewer inline). File: "
|
|
588
|
+
f"<code style='color:#93c5fd'>{out}</code>"
|
|
589
|
+
"</div>"
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
except Exception:
|
|
593
|
+
print(f"plot3: open in browser → {out}")
|
|
594
|
+
if open_browser:
|
|
595
|
+
webbrowser.open(uri)
|
|
596
|
+
return out
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
from IPython.display import HTML, display
|
|
600
|
+
|
|
601
|
+
display(HTML(self._iframe(doc)))
|
|
602
|
+
# Restricted hosts (VS Code) often show a blank panel: keep a file
|
|
603
|
+
# fallback so the figure is never lost when auto-detect misses.
|
|
604
|
+
# The hint itself is once per session, and skipped when the user
|
|
605
|
+
# already asked for an iframe.
|
|
606
|
+
if not _skip_blank_hint():
|
|
607
|
+
out = self._write_preview(path, doc)
|
|
608
|
+
global _BLANK_HINT_SHOWN
|
|
609
|
+
if not _BLANK_HINT_SHOWN:
|
|
610
|
+
_BLANK_HINT_SHOWN = True
|
|
611
|
+
display(
|
|
612
|
+
HTML(
|
|
613
|
+
"<div style='font:12px system-ui,sans-serif;margin-top:6px;"
|
|
614
|
+
"color:#94a3b8'>If the panel above is blank, run "
|
|
615
|
+
"<code style='color:#93c5fd'>fig.show(browser=True)</code> "
|
|
616
|
+
f"or open <code style='color:#93c5fd'>{out}</code></div>"
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
return out
|
|
620
|
+
except Exception:
|
|
621
|
+
out = self._write_preview(path, doc)
|
|
622
|
+
webbrowser.open(out.as_uri())
|
|
623
|
+
return out
|
|
624
|
+
return None
|
|
625
|
+
|
|
626
|
+
def _iframe(self, doc: str | None = None) -> str:
|
|
627
|
+
if doc is None:
|
|
628
|
+
doc = self.html()
|
|
629
|
+
if self._payload is not None:
|
|
630
|
+
spec = self._payload.get("spec") or {}
|
|
631
|
+
title = (spec.get("labs") or {}).get("title") or "plot3 figure"
|
|
632
|
+
surface = (spec.get("theme") or {}).get("surface") or _THEMES[
|
|
633
|
+
self.theme_name
|
|
634
|
+
]["surface"]
|
|
635
|
+
else:
|
|
636
|
+
title = self.labs.get("title", "plot3 figure")
|
|
637
|
+
surface = _THEMES[self.theme_name]["surface"]
|
|
638
|
+
# sandbox must allow scripts or the three.js viewer never starts.
|
|
639
|
+
# allow-downloads: without it, Chrome and Firefox ignore <a download>
|
|
640
|
+
# and raise nothing. clipboard-write lets Copy PNG work in the frame.
|
|
641
|
+
# A wrapping div keeps the string from starting with ``<iframe``,
|
|
642
|
+
# which is what makes IPython suggest display.IFrame.
|
|
643
|
+
return (
|
|
644
|
+
f'<div class="plot3-fig"><iframe srcdoc="{_htmlesc.escape(doc, quote=True)}" '
|
|
645
|
+
f'sandbox="allow-scripts allow-same-origin allow-pointer-lock allow-downloads" '
|
|
646
|
+
f'allow="fullscreen; clipboard-write" '
|
|
647
|
+
f'style="width:100%;height:{self.height};border:0;'
|
|
648
|
+
f'border-radius:6px;background:{surface}" '
|
|
649
|
+
f'title="{_htmlesc.escape(str(title))}"></iframe></div>'
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
AUTOHIDE = True
|
|
654
|
+
_BLANK_HINT_SHOWN = False
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def autohide(on: bool = True) -> None:
|
|
658
|
+
"""Default hide-from-AI behavior for displayed figures (SolveIt red eye)."""
|
|
659
|
+
global AUTOHIDE
|
|
660
|
+
AUTOHIDE = bool(on)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def ggsave(
|
|
664
|
+
filename,
|
|
665
|
+
plot: ggplot | None = None,
|
|
666
|
+
*,
|
|
667
|
+
width=None,
|
|
668
|
+
height=None,
|
|
669
|
+
units: str = "px",
|
|
670
|
+
dpi: float | None = None,
|
|
671
|
+
family: str | None = None,
|
|
672
|
+
fontsize: float | None = None,
|
|
673
|
+
notes: bool = False,
|
|
674
|
+
) -> str:
|
|
675
|
+
"""Save ``plot``.
|
|
676
|
+
|
|
677
|
+
``ggsave("fig.png", p)`` and ``ggsave("fig.svg", p)`` write a static
|
|
678
|
+
image. ``ggsave("fig.pdf", p)`` writes a PDF when ``plot3[export]`` is
|
|
679
|
+
installed. That extra also needs the Cairo C library; ``.svg`` writes
|
|
680
|
+
the same drawing with no extra dependencies. Any other suffix,
|
|
681
|
+
including ``.html``, writes the standalone viewer. The plot and the
|
|
682
|
+
filename can be passed in either order.
|
|
683
|
+
|
|
684
|
+
``width`` and ``height`` are pixels by default, so ``width=640`` stays
|
|
685
|
+
640 pixels. A journal size uses physical units::
|
|
686
|
+
|
|
687
|
+
ggsave("fig.png", p, width=7, height=4, units="in", dpi=300)
|
|
688
|
+
|
|
689
|
+
``dpi`` applies only with ``units`` of ``"in"``, ``"cm"``, or ``"mm"``
|
|
690
|
+
(default 300). ``family`` and ``fontsize`` override the theme font for
|
|
691
|
+
this file. ``fontsize`` is in points. ``$...$`` labels are saved as
|
|
692
|
+
Unicode, the same text the SVG draws.
|
|
693
|
+
|
|
694
|
+
Notes such as ``y clipped to [...]`` are printed, not drawn: they are
|
|
695
|
+
for you, not for readers of the figure. ``notes=True`` draws them.
|
|
696
|
+
"""
|
|
697
|
+
from plot3.compose import Composition
|
|
698
|
+
|
|
699
|
+
figure_types = (ggplot, Composition)
|
|
700
|
+
if isinstance(filename, figure_types) and plot is not None and not isinstance(plot, figure_types):
|
|
701
|
+
filename, plot = plot, filename # tolerate swapped args
|
|
702
|
+
if not isinstance(plot, figure_types):
|
|
703
|
+
raise ValueError("ggsave(filename, plot) needs the plot")
|
|
704
|
+
return plot.save(
|
|
705
|
+
filename,
|
|
706
|
+
width=width,
|
|
707
|
+
height=height,
|
|
708
|
+
units=units,
|
|
709
|
+
dpi=dpi,
|
|
710
|
+
family=family,
|
|
711
|
+
fontsize=fontsize,
|
|
712
|
+
notes=notes,
|
|
713
|
+
)
|