anyplotlib 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.
- anyplotlib/__init__.py +53 -0
- anyplotlib/_base_plot.py +229 -0
- anyplotlib/_electron.py +74 -0
- anyplotlib/_repr_utils.py +345 -0
- anyplotlib/_utils.py +194 -0
- anyplotlib/axes/__init__.py +6 -0
- anyplotlib/axes/_axes.py +510 -0
- anyplotlib/axes/_inset_axes.py +126 -0
- anyplotlib/callbacks.py +328 -0
- anyplotlib/embed.py +194 -0
- anyplotlib/figure/__init__.py +7 -0
- anyplotlib/figure/_figure.py +633 -0
- anyplotlib/figure/_gridspec.py +99 -0
- anyplotlib/figure/_subplots.py +100 -0
- anyplotlib/figure_esm.js +6011 -0
- anyplotlib/markers.py +704 -0
- anyplotlib/plot1d/__init__.py +6 -0
- anyplotlib/plot1d/_plot1d.py +1376 -0
- anyplotlib/plot1d/_plotbar.py +436 -0
- anyplotlib/plot2d/__init__.py +5 -0
- anyplotlib/plot2d/_plot2d.py +726 -0
- anyplotlib/plot2d/_plotmesh.py +116 -0
- anyplotlib/plot3d/__init__.py +4 -0
- anyplotlib/plot3d/_plot3d.py +524 -0
- anyplotlib/plotxy/__init__.py +4 -0
- anyplotlib/plotxy/_plotxy.py +273 -0
- anyplotlib/sphinx_anywidget/__init__.py +177 -0
- anyplotlib/sphinx_anywidget/_directive.py +245 -0
- anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
- anyplotlib/sphinx_anywidget/_scraper.py +390 -0
- anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
- anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
- anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
- anyplotlib/widgets/__init__.py +18 -0
- anyplotlib/widgets/_base.py +218 -0
- anyplotlib/widgets/_widgets1d.py +109 -0
- anyplotlib/widgets/_widgets2d.py +141 -0
- anyplotlib/widgets/_widgets3d.py +50 -0
- anyplotlib-0.1.0.dist-info/METADATA +160 -0
- anyplotlib-0.1.0.dist-info/RECORD +42 -0
- anyplotlib-0.1.0.dist-info/WHEEL +4 -0
- anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinx_anywidget/_directive.py
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
RST directive for embedding interactive anywidget figures directly in ``.rst``
|
|
6
|
+
pages — no Sphinx Gallery required.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
Static snapshot only::
|
|
12
|
+
|
|
13
|
+
.. anywidget-figure:: ../Examples/plot_image2d.py
|
|
14
|
+
|
|
15
|
+
Interactive (Pyodide-activatable)::
|
|
16
|
+
|
|
17
|
+
.. anywidget-figure:: ../Examples/plot_image2d.py
|
|
18
|
+
:interactive:
|
|
19
|
+
|
|
20
|
+
Options
|
|
21
|
+
-------
|
|
22
|
+
``:interactive:``
|
|
23
|
+
Flag. When present the ⚡ activation badge is shown and the example
|
|
24
|
+
source is embedded for live re-execution by the Pyodide bridge.
|
|
25
|
+
``:width:`` (int, default 684)
|
|
26
|
+
Maximum display width in pixels.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json as _json
|
|
33
|
+
import re
|
|
34
|
+
import runpy
|
|
35
|
+
import tempfile
|
|
36
|
+
from html import escape as _html_escape
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
# Reuse the regex parsers from the scraper.
|
|
40
|
+
from anyplotlib.sphinx_anywidget._scraper import (
|
|
41
|
+
_PYODIDE_PACKAGES_RE,
|
|
42
|
+
_PYODIDE_MOCK_PACKAGES_RE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from docutils import nodes
|
|
46
|
+
from docutils.parsers.rst import Directive, directives
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnywidgetFigureDirective(Directive):
|
|
50
|
+
"""Directive: ``.. anywidget-figure:: path/to/script.py``."""
|
|
51
|
+
|
|
52
|
+
required_arguments = 1 # path to the Python source file
|
|
53
|
+
optional_arguments = 0
|
|
54
|
+
has_content = False
|
|
55
|
+
option_spec = {
|
|
56
|
+
"interactive": directives.flag,
|
|
57
|
+
"width": directives.unchanged, # e.g. "684", "100%", "80%"
|
|
58
|
+
"height": directives.unchanged, # e.g. "400px", "400"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def run(self):
|
|
62
|
+
env = self.state.document.settings.env
|
|
63
|
+
config = env.config
|
|
64
|
+
|
|
65
|
+
# ── resolve the source file path ─────────────────────────────────
|
|
66
|
+
src_arg = self.arguments[0]
|
|
67
|
+
# env.confdir was removed in Sphinx 9; fall back to env.app.confdir
|
|
68
|
+
# then env.srcdir so the directive works on all supported versions.
|
|
69
|
+
conf_dir = Path(
|
|
70
|
+
getattr(env, "confdir", None)
|
|
71
|
+
or getattr(env.app, "confdir", None)
|
|
72
|
+
or env.srcdir
|
|
73
|
+
)
|
|
74
|
+
src_path = (conf_dir / src_arg).resolve()
|
|
75
|
+
|
|
76
|
+
if not src_path.exists():
|
|
77
|
+
error = self.reporter.error(
|
|
78
|
+
f"anywidget-figure: source file not found: {src_path}",
|
|
79
|
+
nodes.literal_block(src_arg, src_arg),
|
|
80
|
+
line=self.lineno,
|
|
81
|
+
)
|
|
82
|
+
return [error]
|
|
83
|
+
|
|
84
|
+
# ── options ──────────────────────────────────────────────────────
|
|
85
|
+
is_interactive = "interactive" in self.options
|
|
86
|
+
|
|
87
|
+
# :width: accepts "684" (pixels) or "100%" / "80%" (percentage).
|
|
88
|
+
# A percentage means "use the full container width" — we pass
|
|
89
|
+
# max_width=None so _iframe_html uses its default MAX_DOC_WIDTH cap
|
|
90
|
+
# and the CSS wrapper fills the container via its inline-block rule.
|
|
91
|
+
max_width = None
|
|
92
|
+
raw_width = self.options.get("width", None)
|
|
93
|
+
if raw_width is not None:
|
|
94
|
+
raw_width = str(raw_width).strip()
|
|
95
|
+
if not raw_width.endswith("%"):
|
|
96
|
+
try:
|
|
97
|
+
max_width = int(raw_width.replace("px", "").strip())
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
max_width = None
|
|
100
|
+
# else: percentage → leave max_width=None (full-width default)
|
|
101
|
+
|
|
102
|
+
# :height: accepts "400px" or "400".
|
|
103
|
+
max_height = None
|
|
104
|
+
raw_height = self.options.get("height", None)
|
|
105
|
+
if raw_height is not None:
|
|
106
|
+
try:
|
|
107
|
+
max_height = int(str(raw_height).lower().replace("px", "").strip())
|
|
108
|
+
except (ValueError, TypeError):
|
|
109
|
+
max_height = None
|
|
110
|
+
|
|
111
|
+
# ── execute the script to get the widget ─────────────────────────
|
|
112
|
+
try:
|
|
113
|
+
g = runpy.run_path(str(src_path), run_name="__main__")
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
error = self.reporter.error(
|
|
116
|
+
f"anywidget-figure: failed to execute {src_path.name}: {exc}",
|
|
117
|
+
nodes.literal_block(str(exc), str(exc)),
|
|
118
|
+
line=self.lineno,
|
|
119
|
+
)
|
|
120
|
+
return [error]
|
|
121
|
+
|
|
122
|
+
widget = _find_widget(g)
|
|
123
|
+
if widget is None:
|
|
124
|
+
error = self.reporter.error(
|
|
125
|
+
f"anywidget-figure: no anywidget found in {src_path.name}",
|
|
126
|
+
line=self.lineno,
|
|
127
|
+
)
|
|
128
|
+
return [error]
|
|
129
|
+
|
|
130
|
+
# ── write the standalone HTML file ───────────────────────────────
|
|
131
|
+
from anyplotlib.sphinx_anywidget._repr_utils import (
|
|
132
|
+
build_standalone_html, _widget_px,
|
|
133
|
+
)
|
|
134
|
+
from anyplotlib.sphinx_anywidget._scraper import _iframe_html
|
|
135
|
+
|
|
136
|
+
# Use a stable ID derived from the *full* resolved path so two files
|
|
137
|
+
# with the same basename don't overwrite each other or share a fig_id.
|
|
138
|
+
path_hash = hashlib.md5(str(src_path).encode()).hexdigest()[:12]
|
|
139
|
+
fig_id = f"rst_{path_hash}"
|
|
140
|
+
|
|
141
|
+
# Write the standalone HTML directly into the Sphinx output _static dir
|
|
142
|
+
# so the relative URL we embed in the RST resolves correctly.
|
|
143
|
+
out_dir = Path(env.app.outdir)
|
|
144
|
+
docs_static = out_dir / "_static" / "viewer_widgets"
|
|
145
|
+
docs_static.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
html_name = f"{fig_id}.html"
|
|
147
|
+
html_path = docs_static / html_name
|
|
148
|
+
|
|
149
|
+
inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id)
|
|
150
|
+
html_path.write_text(inner_html, encoding="utf-8")
|
|
151
|
+
|
|
152
|
+
w, h = _widget_px(widget)
|
|
153
|
+
|
|
154
|
+
# Compute relative path from the current RST file's output dir
|
|
155
|
+
# to _static/viewer_widgets/.
|
|
156
|
+
# doc_name is the docname without extension, e.g. "index" or "dev/index".
|
|
157
|
+
# The output HTML sits at {out_dir}/{doc_name}.html, so the number of
|
|
158
|
+
# "../" hops needed to reach {out_dir}/_static/ equals the depth of the
|
|
159
|
+
# *directory* part of the docname (not the filename itself).
|
|
160
|
+
try:
|
|
161
|
+
doc_name = env.docname # e.g. "index" or "dev/index"
|
|
162
|
+
rel_depth = len(Path(doc_name).parent.parts) # parent dirs only
|
|
163
|
+
prefix = "../" * rel_depth
|
|
164
|
+
except Exception:
|
|
165
|
+
prefix = ""
|
|
166
|
+
|
|
167
|
+
src_url = f"{prefix}_static/viewer_widgets/{html_name}"
|
|
168
|
+
iframe_kw = {}
|
|
169
|
+
if max_width is not None:
|
|
170
|
+
iframe_kw["max_width"] = max_width
|
|
171
|
+
if max_height is not None:
|
|
172
|
+
iframe_kw["max_height"] = max_height
|
|
173
|
+
iframe_block = _iframe_html(
|
|
174
|
+
src_url, w, h,
|
|
175
|
+
fig_id=fig_id,
|
|
176
|
+
interactive=is_interactive,
|
|
177
|
+
**iframe_kw,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
raw_html = "\n" + iframe_block + "\n"
|
|
181
|
+
|
|
182
|
+
if is_interactive:
|
|
183
|
+
python_src = ""
|
|
184
|
+
try:
|
|
185
|
+
python_src = src_path.read_text(encoding="utf-8")
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
if python_src:
|
|
190
|
+
data_src = _html_escape(_json.dumps(python_src), quote=True)
|
|
191
|
+
|
|
192
|
+
# Detect _PYODIDE_PACKAGES = [...] in the source.
|
|
193
|
+
_pkg_attr = ""
|
|
194
|
+
m = _PYODIDE_PACKAGES_RE.search(python_src)
|
|
195
|
+
if m:
|
|
196
|
+
try:
|
|
197
|
+
import ast as _ast
|
|
198
|
+
pkgs = _ast.literal_eval(m.group(1))
|
|
199
|
+
if pkgs:
|
|
200
|
+
_pkg_attr = (
|
|
201
|
+
f' data-pyodide-packages='
|
|
202
|
+
f'"{_html_escape(_json.dumps(pkgs), quote=True)}"'
|
|
203
|
+
)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
# Detect _PYODIDE_MOCK_PACKAGES = [...] in the source.
|
|
208
|
+
_mock_attr = ""
|
|
209
|
+
m2 = _PYODIDE_MOCK_PACKAGES_RE.search(python_src)
|
|
210
|
+
if m2:
|
|
211
|
+
try:
|
|
212
|
+
import ast as _ast2
|
|
213
|
+
mock_pkgs = _ast2.literal_eval(m2.group(1))
|
|
214
|
+
if mock_pkgs:
|
|
215
|
+
_mock_attr = (
|
|
216
|
+
f' data-pyodide-mock-packages='
|
|
217
|
+
f'"{_html_escape(_json.dumps(mock_pkgs), quote=True)}"'
|
|
218
|
+
)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
stem = src_path.stem
|
|
223
|
+
script_tag = (
|
|
224
|
+
f'<script type="text/x-python"'
|
|
225
|
+
f' data-fig-id="{fig_id}"'
|
|
226
|
+
f' data-fig-index="0"'
|
|
227
|
+
f' data-src-file="{stem}"'
|
|
228
|
+
f'{_pkg_attr}'
|
|
229
|
+
f'{_mock_attr}'
|
|
230
|
+
f' data-src="{data_src}"></script>'
|
|
231
|
+
)
|
|
232
|
+
raw_html += "\n" + script_tag + "\n"
|
|
233
|
+
|
|
234
|
+
return [nodes.raw("", raw_html, format="html")]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _find_widget(globals_dict: dict):
|
|
238
|
+
"""Locate the most-recently created anywidget in *globals_dict*."""
|
|
239
|
+
for val in reversed(list(globals_dict.values())):
|
|
240
|
+
if not callable(getattr(val, "_repr_html_", None)):
|
|
241
|
+
continue
|
|
242
|
+
if hasattr(val, "_esm") and hasattr(val, "traits"):
|
|
243
|
+
return val
|
|
244
|
+
return None
|
|
245
|
+
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinx_anywidget/_repr_utils.py
|
|
3
|
+
================================
|
|
4
|
+
|
|
5
|
+
Self-contained HTML builder for any ``anywidget.AnyWidget`` subclass.
|
|
6
|
+
No runtime dependency on anyplotlib or any specific widget library.
|
|
7
|
+
|
|
8
|
+
Strategy
|
|
9
|
+
--------
|
|
10
|
+
1. Serialise every ``sync=True`` traitlet to a plain JSON dict.
|
|
11
|
+
2. Embed that dict and the widget's ``_esm`` source directly in the page.
|
|
12
|
+
3. Provide a minimal model shim (get/set/on/save_changes) so the ESM's
|
|
13
|
+
render() function works without any Jupyter comm infrastructure.
|
|
14
|
+
4. Import the ESM as a Blob URL and call ``render({ model, el })``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from html import escape
|
|
21
|
+
from uuid import uuid4
|
|
22
|
+
|
|
23
|
+
# Maximum display width (px) for the non-resizable notebook embed.
|
|
24
|
+
MAX_NOTEBOOK_WIDTH = 860
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Trait serialisation
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _widget_state(widget) -> dict:
|
|
32
|
+
"""Return a {name: value} dict of every synced traitlet."""
|
|
33
|
+
state: dict = {}
|
|
34
|
+
for name, trait in widget.traits(sync=True).items():
|
|
35
|
+
if name.startswith("_"):
|
|
36
|
+
continue
|
|
37
|
+
raw = getattr(widget, name)
|
|
38
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
39
|
+
import base64
|
|
40
|
+
raw = {"buffer": base64.b64encode(raw).decode("ascii")}
|
|
41
|
+
state[name] = raw
|
|
42
|
+
return state
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _widget_px(widget) -> tuple[int, int]:
|
|
46
|
+
"""Return ``(width_px, height_px)`` for any anywidget subclass.
|
|
47
|
+
|
|
48
|
+
Tries common trait names in priority order before falling back to a
|
|
49
|
+
sensible default. Widget authors can override by adding
|
|
50
|
+
``_display_width`` and ``_display_height`` *non-synced* attributes.
|
|
51
|
+
"""
|
|
52
|
+
# Explicit override
|
|
53
|
+
if hasattr(widget, "_display_width") and hasattr(widget, "_display_height"):
|
|
54
|
+
return int(widget._display_width), int(widget._display_height)
|
|
55
|
+
|
|
56
|
+
kind = type(widget).__name__
|
|
57
|
+
|
|
58
|
+
# anyplotlib Figure — gridDiv adds 16 px padding on each side
|
|
59
|
+
if kind == "Figure":
|
|
60
|
+
try:
|
|
61
|
+
return int(widget.fig_width) + 16, int(widget.fig_height) + 16
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# Common viewer patterns: viewer_width / viewer_height traits
|
|
66
|
+
if hasattr(widget, "viewer_width") and hasattr(widget, "viewer_height"):
|
|
67
|
+
return int(widget.viewer_width) + 20, int(widget.viewer_height) + 20
|
|
68
|
+
|
|
69
|
+
# width / height traits
|
|
70
|
+
if hasattr(widget, "width") and hasattr(widget, "height"):
|
|
71
|
+
try:
|
|
72
|
+
return int(widget.width), int(widget.height)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return 560, 340
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# HTML builder
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_NO_RESIZE_CSS = """\
|
|
84
|
+
/* ── resizable=False overrides ─────────────────────────────── */
|
|
85
|
+
div[style*="nwse-resize"],
|
|
86
|
+
div[title="Drag to resize"],
|
|
87
|
+
div[title="Drag to resize figure"] {{
|
|
88
|
+
display: none !important;
|
|
89
|
+
}}
|
|
90
|
+
#widget-root > div {{
|
|
91
|
+
padding-bottom: 0 !important;
|
|
92
|
+
padding-right: 0 !important;
|
|
93
|
+
}}
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
_PAGE_TEMPLATE = """\
|
|
97
|
+
<!DOCTYPE html>
|
|
98
|
+
<html>
|
|
99
|
+
<head>
|
|
100
|
+
<meta charset="utf-8"/>
|
|
101
|
+
<style>
|
|
102
|
+
html, body {{
|
|
103
|
+
margin: 0;
|
|
104
|
+
padding: 0;
|
|
105
|
+
background: transparent;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
width: {width}px;
|
|
108
|
+
height: {height}px;
|
|
109
|
+
}}
|
|
110
|
+
#widget-root {{
|
|
111
|
+
display: inline-block;
|
|
112
|
+
line-height: 0;
|
|
113
|
+
}}
|
|
114
|
+
{extra_css}\
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
<div id="widget-root"></div>
|
|
119
|
+
<script type="module">
|
|
120
|
+
const STATE = {state_json};
|
|
121
|
+
// Identifies this iframe to the parent-page anywidget bridge.
|
|
122
|
+
// null → no Pyodide wiring (plain notebook / static docs embed).
|
|
123
|
+
const FIG_ID = {fig_id_json};
|
|
124
|
+
|
|
125
|
+
// Loop-prevention flag: set while applying a parent-originated update so
|
|
126
|
+
// save_changes() doesn't echo the change back as an awi_event.
|
|
127
|
+
let _fromParent = false;
|
|
128
|
+
// Dirty flag: true only when model.set('event_json', ...) was called in the
|
|
129
|
+
// current transaction.
|
|
130
|
+
let _eventJsonDirty = false;
|
|
131
|
+
|
|
132
|
+
function makeModel(state) {{
|
|
133
|
+
const _data = Object.assign({{}}, state);
|
|
134
|
+
const _cbs = {{}};
|
|
135
|
+
const _anyCbs = [];
|
|
136
|
+
return {{
|
|
137
|
+
get(key) {{ return _data[key]; }},
|
|
138
|
+
set(key, val) {{
|
|
139
|
+
_data[key] = val;
|
|
140
|
+
if (key === 'event_json') _eventJsonDirty = true;
|
|
141
|
+
}},
|
|
142
|
+
save_changes() {{
|
|
143
|
+
for (const [ev, cbs] of Object.entries(_cbs))
|
|
144
|
+
for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
|
|
145
|
+
for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
|
|
146
|
+
// Forward interaction events to the parent-page Pyodide instance.
|
|
147
|
+
if (!_fromParent && FIG_ID && window.parent !== window && _eventJsonDirty) {{
|
|
148
|
+
_eventJsonDirty = false;
|
|
149
|
+
try {{
|
|
150
|
+
const ev = JSON.parse(_data.event_json || '{{}}');
|
|
151
|
+
if (ev && ev.source !== 'python') {{
|
|
152
|
+
window.parent.postMessage(
|
|
153
|
+
{{ type: 'awi_event', figId: FIG_ID, data: _data.event_json }}, '*');
|
|
154
|
+
}}
|
|
155
|
+
}} catch(_) {{}}
|
|
156
|
+
}} else {{
|
|
157
|
+
_eventJsonDirty = false;
|
|
158
|
+
}}
|
|
159
|
+
}},
|
|
160
|
+
on(event, cb) {{
|
|
161
|
+
if (event === "change") {{ _anyCbs.push(cb); return; }}
|
|
162
|
+
(_cbs[event] = _cbs[event] || []).push(cb);
|
|
163
|
+
}},
|
|
164
|
+
off(event, cb) {{
|
|
165
|
+
if (!event) {{ for (const k in _cbs) _cbs[k]=[]; _anyCbs.length=0; return; }}
|
|
166
|
+
if (_cbs[event]) _cbs[event] = _cbs[event].filter(c => c !== cb);
|
|
167
|
+
}},
|
|
168
|
+
get model() {{ return this; }},
|
|
169
|
+
}};
|
|
170
|
+
}}
|
|
171
|
+
|
|
172
|
+
const esmSource = {esm_json};
|
|
173
|
+
const blob = new Blob([esmSource], {{ type: "text/javascript" }});
|
|
174
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
175
|
+
const el = document.getElementById("widget-root");
|
|
176
|
+
const model = makeModel(STATE);
|
|
177
|
+
|
|
178
|
+
import(blobUrl).then(mod => {{
|
|
179
|
+
const renderFn = mod.default?.render ?? mod.render;
|
|
180
|
+
if (typeof renderFn === "function") {{
|
|
181
|
+
renderFn({{ model, el }});
|
|
182
|
+
}} else {{
|
|
183
|
+
el.textContent = "ESM has no render() export";
|
|
184
|
+
}}
|
|
185
|
+
}}).catch(err => {{
|
|
186
|
+
el.textContent = "Widget load error: " + err;
|
|
187
|
+
console.error(err);
|
|
188
|
+
}});
|
|
189
|
+
|
|
190
|
+
// ── Inbound state updates from parent-page Pyodide ───────────────────────────
|
|
191
|
+
window.addEventListener('message', (e) => {{
|
|
192
|
+
if (!e.data || e.data.type !== 'awi_state') return;
|
|
193
|
+
_fromParent = true;
|
|
194
|
+
model.set(e.data.key, e.data.value);
|
|
195
|
+
model.save_changes();
|
|
196
|
+
_fromParent = false;
|
|
197
|
+
}});
|
|
198
|
+
</script>
|
|
199
|
+
</body>
|
|
200
|
+
</html>
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_standalone_html(widget, *, resizable: bool = True,
|
|
205
|
+
fig_id: str | None = None) -> str:
|
|
206
|
+
"""Return a self-contained HTML page that renders *widget* interactively.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
widget :
|
|
211
|
+
Any ``anywidget.AnyWidget`` subclass with ``_esm`` defined.
|
|
212
|
+
resizable : bool
|
|
213
|
+
When ``True`` (default) the widget's built-in resize handle is
|
|
214
|
+
preserved. When ``False`` the handle is hidden and the page is
|
|
215
|
+
sized exactly to the widget's natural dimensions.
|
|
216
|
+
fig_id : str or None
|
|
217
|
+
When provided, embedded as ``FIG_ID`` so the parent-page bridge
|
|
218
|
+
can route ``postMessage`` state updates to this iframe.
|
|
219
|
+
"""
|
|
220
|
+
state = _widget_state(widget)
|
|
221
|
+
|
|
222
|
+
esm = getattr(widget, "_esm", "") or ""
|
|
223
|
+
if hasattr(esm, "read_text"):
|
|
224
|
+
esm = esm.read_text(encoding="utf-8")
|
|
225
|
+
esm = str(esm)
|
|
226
|
+
|
|
227
|
+
w, h = _widget_px(widget)
|
|
228
|
+
extra_css = _NO_RESIZE_CSS.format() if not resizable else ""
|
|
229
|
+
|
|
230
|
+
return _PAGE_TEMPLATE.format(
|
|
231
|
+
width=w,
|
|
232
|
+
height=h,
|
|
233
|
+
extra_css=extra_css,
|
|
234
|
+
state_json=json.dumps(state, default=str),
|
|
235
|
+
esm_json=json.dumps(esm),
|
|
236
|
+
fig_id_json=json.dumps(fig_id),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def repr_html_iframe(widget, *, resizable: bool = False,
|
|
241
|
+
max_width: int = MAX_NOTEBOOK_WIDTH,
|
|
242
|
+
max_height: int = 800) -> str:
|
|
243
|
+
"""Return a centred, responsive ``<iframe srcdoc=...>`` embedding *widget*."""
|
|
244
|
+
inner_html = build_standalone_html(widget, resizable=resizable)
|
|
245
|
+
escaped = escape(inner_html, quote=True)
|
|
246
|
+
uid = str(uuid4()).replace("-", "")
|
|
247
|
+
|
|
248
|
+
w, h = _widget_px(widget)
|
|
249
|
+
|
|
250
|
+
if not resizable:
|
|
251
|
+
init_scale = min(1.0, max_width / w)
|
|
252
|
+
init_w = round(w * init_scale)
|
|
253
|
+
init_h = round(h * init_scale)
|
|
254
|
+
scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
|
|
255
|
+
|
|
256
|
+
js = (
|
|
257
|
+
f"(function(){{"
|
|
258
|
+
f"var wrap=document.getElementById('vw-{uid}'),"
|
|
259
|
+
f"ifr=wrap.querySelector('iframe'),"
|
|
260
|
+
f"nw={w},nh={h};"
|
|
261
|
+
f"function r(){{"
|
|
262
|
+
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
|
|
263
|
+
f"if(!avail)return;"
|
|
264
|
+
f"var s=Math.min(1,avail/nw);"
|
|
265
|
+
f"wrap.style.width=Math.round(nw*s)+'px';"
|
|
266
|
+
f"wrap.style.height=Math.round(nh*s)+'px';"
|
|
267
|
+
f"ifr.style.transform='scale('+s+')';"
|
|
268
|
+
f"}}"
|
|
269
|
+
f"requestAnimationFrame(r);window.addEventListener('resize',r);"
|
|
270
|
+
f"}})()"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
f'<div style="display:block;text-align:center;line-height:0;margin:8px 0;">'
|
|
275
|
+
f'<div id="vw-{uid}" style="display:inline-block;overflow:hidden;'
|
|
276
|
+
f'position:relative;width:{init_w}px;height:{init_h}px;">'
|
|
277
|
+
f'<iframe srcdoc="{escaped}" frameborder="0" scrolling="no" '
|
|
278
|
+
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
|
|
279
|
+
f'transform-origin:top left;transform:scale({scale_css});'
|
|
280
|
+
f'position:absolute;top:0;left:0;">'
|
|
281
|
+
f'</iframe>'
|
|
282
|
+
f'</div>'
|
|
283
|
+
f'<script>{js}</script>'
|
|
284
|
+
f'</div>'
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
return (
|
|
288
|
+
f'<iframe id="vw-{uid}" srcdoc="{escaped}" frameborder="0" '
|
|
289
|
+
f'style="width:100%;height:{h}px;border:none;overflow:hidden;" '
|
|
290
|
+
f'onload="setTimeout(function(){{'
|
|
291
|
+
f'var f=document.getElementById(\'vw-{uid}\');'
|
|
292
|
+
f'if(f&&f.contentWindow&&f.contentWindow.document.body){{'
|
|
293
|
+
f'f.style.height=Math.min('
|
|
294
|
+
f'f.contentWindow.document.body.scrollHeight+20,{max_height})+\'px\''
|
|
295
|
+
f'}}}},'
|
|
296
|
+
f'300)"></iframe>'
|
|
297
|
+
)
|
|
298
|
+
|