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