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.
Files changed (42) hide show
  1. anyplotlib/__init__.py +53 -0
  2. anyplotlib/_base_plot.py +229 -0
  3. anyplotlib/_electron.py +74 -0
  4. anyplotlib/_repr_utils.py +345 -0
  5. anyplotlib/_utils.py +194 -0
  6. anyplotlib/axes/__init__.py +6 -0
  7. anyplotlib/axes/_axes.py +510 -0
  8. anyplotlib/axes/_inset_axes.py +126 -0
  9. anyplotlib/callbacks.py +328 -0
  10. anyplotlib/embed.py +194 -0
  11. anyplotlib/figure/__init__.py +7 -0
  12. anyplotlib/figure/_figure.py +633 -0
  13. anyplotlib/figure/_gridspec.py +99 -0
  14. anyplotlib/figure/_subplots.py +100 -0
  15. anyplotlib/figure_esm.js +6011 -0
  16. anyplotlib/markers.py +704 -0
  17. anyplotlib/plot1d/__init__.py +6 -0
  18. anyplotlib/plot1d/_plot1d.py +1376 -0
  19. anyplotlib/plot1d/_plotbar.py +436 -0
  20. anyplotlib/plot2d/__init__.py +5 -0
  21. anyplotlib/plot2d/_plot2d.py +726 -0
  22. anyplotlib/plot2d/_plotmesh.py +116 -0
  23. anyplotlib/plot3d/__init__.py +4 -0
  24. anyplotlib/plot3d/_plot3d.py +524 -0
  25. anyplotlib/plotxy/__init__.py +4 -0
  26. anyplotlib/plotxy/_plotxy.py +273 -0
  27. anyplotlib/sphinx_anywidget/__init__.py +177 -0
  28. anyplotlib/sphinx_anywidget/_directive.py +245 -0
  29. anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
  30. anyplotlib/sphinx_anywidget/_scraper.py +390 -0
  31. anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
  32. anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
  33. anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
  34. anyplotlib/widgets/__init__.py +18 -0
  35. anyplotlib/widgets/_base.py +218 -0
  36. anyplotlib/widgets/_widgets1d.py +109 -0
  37. anyplotlib/widgets/_widgets2d.py +141 -0
  38. anyplotlib/widgets/_widgets3d.py +50 -0
  39. anyplotlib-0.1.0.dist-info/METADATA +160 -0
  40. anyplotlib-0.1.0.dist-info/RECORD +42 -0
  41. anyplotlib-0.1.0.dist-info/WHEEL +4 -0
  42. anyplotlib-0.1.0.dist-info/licenses/LICENSE +21 -0
anyplotlib/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
2
+
3
+ try:
4
+ __version__ = _pkg_version("anyplotlib")
5
+ except PackageNotFoundError: # not installed (e.g. source tree on sys.path)
6
+ __version__ = "0.0.0+unknown"
7
+
8
+ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots
9
+ from anyplotlib.axes import Axes, InsetAxes
10
+ from anyplotlib.plot1d import Plot1D, PlotBar
11
+ from anyplotlib.plot1d._plot1d import Line1D
12
+ from anyplotlib.plot2d import Plot2D, PlotMesh
13
+ from anyplotlib.plot3d import Plot3D
14
+ from anyplotlib.plotxy import PlotXY
15
+ from anyplotlib.callbacks import CallbackRegistry, Event
16
+ from anyplotlib import embed
17
+ from anyplotlib.markers import MarkerRegistry, MarkerGroup
18
+ from anyplotlib.widgets import (
19
+ Widget, RectangleWidget, CircleWidget, AnnularWidget,
20
+ CrosshairWidget, PolygonWidget, LabelWidget,
21
+ VLineWidget, HLineWidget, RangeWidget, PlaneWidget,
22
+ )
23
+
24
+ # ── Global help flag ──────────────────────────────────────────────────────
25
+ # Set to False to suppress help badges on all figures in this session.
26
+ # Default True: badges appear whenever a figure has help text set.
27
+ show_help: bool = True
28
+
29
+ _COLOR_CYCLE: list[str] = [
30
+ "#4fc3f7", "#ff7043", "#aed581", "#ffd54f",
31
+ "#ba68c8", "#4db6ac", "#f06292", "#90a4ae",
32
+ "#ffb74d", "#a5d6a7",
33
+ ]
34
+
35
+
36
+ def get_color_cycle() -> list[str]:
37
+ """Return the default color cycle as a list of CSS hex strings."""
38
+ return list(_COLOR_CYCLE)
39
+
40
+
41
+ __all__ = [
42
+ "Figure", "GridSpec", "SubplotSpec", "subplots",
43
+ "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar",
44
+ "Line1D",
45
+ "CallbackRegistry", "Event",
46
+ "MarkerRegistry", "MarkerGroup",
47
+ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget",
48
+ "CrosshairWidget", "PolygonWidget", "LabelWidget",
49
+ "VLineWidget", "HLineWidget", "RangeWidget", "PlaneWidget",
50
+ "show_help", "get_color_cycle",
51
+ "embed",
52
+ "__version__",
53
+ ]
@@ -0,0 +1,229 @@
1
+ """
2
+ _base_plot.py
3
+ =============
4
+ Shared base classes and mixins for all plot panel types.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+
11
+ from anyplotlib.callbacks import _EventMixin
12
+
13
+
14
+ class _BasePlot(_EventMixin):
15
+ """Universal base for Plot1D, Plot2D, PlotBar, and Plot3D.
16
+
17
+ Contains methods identical across all four panel types and helper
18
+ utilities used by view-setter and widget-adder methods.
19
+
20
+ Subclasses must define:
21
+ _state : dict — the panel state dict
22
+ _push() -> None — serialize state and write to parent Figure
23
+ """
24
+
25
+ def configure_pointer_settled(self, ms: int, delta: float = 4) -> None:
26
+ """Configure the pointer-settled event threshold (ms and pixel delta)."""
27
+ self._state["pointer_settled_ms"] = ms
28
+ self._state["pointer_settled_delta"] = delta
29
+ self._push()
30
+
31
+ _configure_pointer_settled = configure_pointer_settled
32
+
33
+ #: Mini-TeX formatting note shared by all label setters.
34
+ #:
35
+ #: Label strings support a small TeX subset inside ``$...$`` delimiters,
36
+ #: rendered by the JS canvas engine (no MathJax needed):
37
+ #:
38
+ #: * ``$10^{-3}$`` / ``$x^2$`` — superscripts (exponents)
39
+ #: * ``$E_F$`` / ``$k_{B}T$`` — subscripts
40
+ #: * ``$\\alpha$ … $\\Omega$`` — Greek letters
41
+ #: * ``\\times \\cdot \\pm \\degree \\AA \\infty \\propto \\approx``
42
+ #: ``\\leq \\geq \\neq \\partial \\nabla \\hbar \\rightarrow`` — symbols
43
+ #: * ``$\\mathrm{...}$`` — upright text inside math (letters in
44
+ #: math mode are italic by default)
45
+ #:
46
+ #: Example: ``plot.set_xlabel(r"$q$ ($\\AA^{-1}$)", fontsize=14)``
47
+
48
+ def _set_label(self, key: str, label: str, size_key: str,
49
+ fontsize: float | None) -> None:
50
+ """Store a label string (TeX subset allowed) and its optional size."""
51
+ self._state[key] = str(label)
52
+ if fontsize is not None:
53
+ self._state[size_key] = float(fontsize)
54
+ self._push()
55
+
56
+ def set_title(self, label: str, fontsize: float | None = None) -> None:
57
+ """Set the panel title.
58
+
59
+ Parameters
60
+ ----------
61
+ label : str
62
+ Title text. Supports the mini-TeX subset (``$10^{-3}$``,
63
+ ``$\\alpha$``, …) — see the class notes on label formatting.
64
+ fontsize : float, optional
65
+ Font size in CSS pixels. Default 11. On 2-D panels the title
66
+ strip grows to fit larger sizes. 1-D and bar titles render in a
67
+ fixed 12-px strip, so the drawn size is clamped to 11 there.
68
+ """
69
+ self._set_label("title", label, "title_size", fontsize)
70
+
71
+ def set_axis_off(self) -> None:
72
+ self._state["axis_visible"] = False
73
+ self._push()
74
+
75
+ def set_axis_on(self) -> None:
76
+ self._state["axis_visible"] = True
77
+ self._push()
78
+
79
+ @contextmanager
80
+ def _python_view_push(self):
81
+ """Context manager for view setters that must signal _view_from_python.
82
+
83
+ Sets the flag on entry, yields for state mutations, then pushes
84
+ and clears the flag on exit.
85
+ """
86
+ self._state["_view_from_python"] = True
87
+ try:
88
+ yield
89
+ finally:
90
+ self._push()
91
+ self._state["_view_from_python"] = False
92
+
93
+ def _make_widget_push_fn(self, widget):
94
+ """Return a targeted-push closure for a widget.
95
+
96
+ Replaces the repeated _tp / _targeted_push closures in every
97
+ add_*_widget method.
98
+ """
99
+ plot_ref, wid_id = self, widget._id
100
+ def _push():
101
+ if plot_ref._fig is not None:
102
+ fields = {k: v for k, v in widget._data.items()
103
+ if k not in ("id", "type")}
104
+ plot_ref._fig._push_widget(plot_ref._id, wid_id, fields)
105
+ return _push
106
+
107
+
108
+ class _PanelMixin:
109
+ """Mixin for panels that support interactive widgets and tick control.
110
+
111
+ Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget
112
+ serialization), widget management, and tick visibility control.
113
+
114
+ Subclasses must define:
115
+ _state : dict
116
+ _fig : object
117
+ _id : str
118
+ _widgets : dict[str, Widget]
119
+ """
120
+
121
+ def _push(self) -> None:
122
+ if self._fig is None:
123
+ return
124
+ self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
125
+ self._fig._push(self._id)
126
+
127
+ def set_tick_label_size(self, size: float) -> None:
128
+ """Set the font size of the tick (axis number) labels in CSS pixels.
129
+
130
+ Applies to both axes of the panel. Default 10.
131
+
132
+ Parameters
133
+ ----------
134
+ size : float
135
+ Tick label font size in pixels.
136
+ """
137
+ self._state["tick_size"] = float(size)
138
+ self._push()
139
+
140
+ def set_ticks_visible(self, visible: bool, *, x: bool | None = None,
141
+ y: bool | None = None) -> None:
142
+ if x is None and y is None:
143
+ self._state["x_ticks_visible"] = bool(visible)
144
+ self._state["y_ticks_visible"] = bool(visible)
145
+ else:
146
+ if x is not None:
147
+ self._state["x_ticks_visible"] = bool(x)
148
+ if y is not None:
149
+ self._state["y_ticks_visible"] = bool(y)
150
+ self._push()
151
+
152
+ def get_widget(self, wid):
153
+ """Return the Widget object by ID string or Widget instance."""
154
+ from anyplotlib.widgets import Widget
155
+ if isinstance(wid, Widget):
156
+ wid = wid.id
157
+ try:
158
+ return self._widgets[wid]
159
+ except KeyError:
160
+ raise KeyError(wid)
161
+
162
+ def remove_widget(self, wid) -> None:
163
+ """Remove a widget by ID string or Widget instance."""
164
+ from anyplotlib.widgets import Widget
165
+ if isinstance(wid, Widget):
166
+ wid = wid.id
167
+ if wid not in self._widgets:
168
+ raise KeyError(wid)
169
+ del self._widgets[wid]
170
+ self._push()
171
+
172
+ def list_widgets(self) -> list:
173
+ """Return a list of all active widget objects on this panel."""
174
+ return list(self._widgets.values())
175
+
176
+ def clear_widgets(self) -> None:
177
+ """Remove all interactive overlay widgets from this panel."""
178
+ self._widgets.clear()
179
+ self._push()
180
+
181
+
182
+ class _MarkerMixin:
183
+ """Mixin for panels that support static marker collections.
184
+
185
+ Shared by Plot1D and Plot2D.
186
+
187
+ Subclasses must define:
188
+ _state : dict
189
+ markers : MarkerRegistry
190
+ _push() -> None
191
+ """
192
+
193
+ def _push_markers(self) -> None:
194
+ self._state["markers"] = self.markers.to_wire_list()
195
+ self._push()
196
+
197
+ def _add_marker(self, mtype: str, name, **kwargs):
198
+ return self.markers.add(mtype, name, **kwargs)
199
+
200
+ def remove_marker(self, marker_type: str, name: str) -> None:
201
+ """Remove a named marker collection by type and name.
202
+
203
+ Parameters
204
+ ----------
205
+ marker_type : str
206
+ Collection type, e.g. ``"points"``, ``"vlines"``.
207
+ name : str
208
+ The name used when the collection was created.
209
+ """
210
+ self.markers.remove(marker_type, name)
211
+
212
+ def clear_markers(self) -> None:
213
+ """Remove all marker collections from this panel."""
214
+ self.markers.clear()
215
+
216
+ def list_markers(self) -> list:
217
+ """Return a summary list of all marker collections on this panel.
218
+
219
+ Returns
220
+ -------
221
+ list of dict
222
+ Each dict has keys ``"type"``, ``"name"``, and ``"n"``
223
+ (number of markers in the collection).
224
+ """
225
+ out = []
226
+ for mtype, td in self.markers._types.items():
227
+ for name, g in td.items():
228
+ out.append({"type": mtype, "name": name, "n": g._count()})
229
+ return out
@@ -0,0 +1,74 @@
1
+ """
2
+ _electron.py
3
+ ============
4
+ Electron app bridge for anyplotlib figures.
5
+
6
+ Registers figures so their trait changes are forwarded to the Electron
7
+ renderer via stdout, and provides dispatch_event() so the renderer can
8
+ send interaction events back to Python.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import uuid
15
+
16
+ _figures: dict[str, object] = {} # fig_id -> Figure
17
+
18
+
19
+ def register(fig) -> str:
20
+ """Register *fig* for bidirectional state sync and return its fig_id."""
21
+ fig_id = uuid.uuid4().hex[:8]
22
+ _figures[fig_id] = fig
23
+
24
+ def _on_change(change):
25
+ name = change["name"]
26
+ value = change["new"]
27
+ if isinstance(value, (bytes, bytearray)):
28
+ import base64
29
+ value = {"buffer": base64.b64encode(value).decode()}
30
+ emit({"type": "state_update", "fig_id": fig_id, "key": name, "value": value})
31
+
32
+ for name in fig.traits(sync=True):
33
+ if not name.startswith("_"):
34
+ try:
35
+ fig.observe(_on_change, names=[name])
36
+ except Exception:
37
+ pass
38
+
39
+ return fig_id
40
+
41
+
42
+ def resize_figure(fig_id: str, width: int, height: int) -> None:
43
+ """Update fig_width / fig_height and push new layout to the iframe."""
44
+ fig = _figures.get(fig_id)
45
+ if fig is None:
46
+ return
47
+ try:
48
+ # Batch both trait changes so _on_resize fires only once each.
49
+ with fig.hold_trait_notifications():
50
+ fig.fig_width = int(width)
51
+ fig.fig_height = int(height)
52
+ except Exception:
53
+ pass
54
+
55
+
56
+ def dispatch_event(fig_id: str, event_json: str) -> None:
57
+ """Apply a frontend interaction event to the registered figure."""
58
+ fig = _figures.get(fig_id)
59
+ if fig is None:
60
+ return
61
+ try:
62
+ # Figure.show() registers Figure objects which use _dispatch_event(raw_json_str).
63
+ # Standalone widgets use _update_from_js(dict, event_type).
64
+ if hasattr(fig, "_dispatch_event"):
65
+ fig._dispatch_event(event_json)
66
+ elif hasattr(fig, "_update_from_js"):
67
+ fig._update_from_js(json.loads(event_json))
68
+ except Exception:
69
+ pass
70
+
71
+
72
+ def emit(obj: dict) -> None:
73
+ sys.stdout.write(f"PLOTAPP:{json.dumps(obj, default=str)}\n")
74
+ sys.stdout.flush()
@@ -0,0 +1,345 @@
1
+ """
2
+ _repr_utils.py
3
+ ==============
4
+
5
+ Produces a self-contained HTML page that renders anywidget Widgets
6
+ interactively without a live Jupyter kernel.
7
+
8
+ Strategy
9
+ --------
10
+ 1. Serialise every synced traitlet value 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
+ When resizable=False (the default for documentation) the resize handle is
17
+ hidden via CSS and the iframe is sized exactly to the widget's own dimensions,
18
+ producing a tight, centred embed with no dead space.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from html import escape
25
+ from uuid import uuid4
26
+
27
+ # Maximum display width (px) for the non-resizable notebook embed.
28
+ # Figures wider than this are scaled down proportionally via CSS transform.
29
+ # 860 px fits comfortably in a standard JupyterLab / VS Code notebook cell.
30
+ MAX_NOTEBOOK_WIDTH = 860
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Trait serialisation
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def _widget_state(widget) -> dict:
38
+ """Return a {name: value} dict of every synced traitlet."""
39
+ state: dict = {}
40
+ for name, trait in widget.traits(sync=True).items():
41
+ if name.startswith("_"):
42
+ continue
43
+ raw = getattr(widget, name)
44
+ if isinstance(raw, (bytes, bytearray)):
45
+ import base64
46
+ raw = {"buffer": base64.b64encode(raw).decode("ascii")}
47
+ state[name] = raw
48
+ return state
49
+
50
+
51
+ def _widget_px(widget) -> tuple[int, int]:
52
+ """Return (width_px, height_px) for the widget's full rendered size.
53
+
54
+ These are the *outer* pixel dimensions of the widget's root DOM element,
55
+ including any padding the widget JS adds around the canvas grid.
56
+ """
57
+ try:
58
+ kind = type(widget).__name__
59
+ if kind == "Figure":
60
+ # figure_esm.js: gridDiv has padding:8px on all sides → +16 each axis
61
+ return int(widget.fig_width) + 16, int(widget.fig_height) + 16
62
+ # Viewer1D / Viewer2D — the outerContainer has padding:10px
63
+ w = int(getattr(widget, "viewer_width", 480))
64
+ h = int(getattr(widget, "viewer_height", 256))
65
+ PAD = 20 # 10px padding each side
66
+ if kind == "Viewer2D":
67
+ # Add axis canvas gutters (AXIS_SIZE = 40 in viewer2d JS)
68
+ AXIS = 40
69
+ w += AXIS + PAD
70
+ h += AXIS + PAD
71
+ if getattr(widget, "histogram_visible", False):
72
+ h_gap = int(getattr(widget, "gap", 10))
73
+ h_hw = int(getattr(widget, "histogram_width", 120))
74
+ w += h_hw + h_gap
75
+ else:
76
+ # Viewer1D: PAD_L=58, PAD_R=12, PAD_T=12, PAD_B=36 + outer 10px pad each side
77
+ w += 58 + 12 + 20 # PAD_L + PAD_R + outer
78
+ h += 12 + 36 + 20 # PAD_T + PAD_B + outer
79
+ return w, h
80
+ except Exception:
81
+ return 560, 340
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # HTML builder
86
+ # ---------------------------------------------------------------------------
87
+
88
+ # Extra CSS injected when resizable=False:
89
+ # - hides every resize-handle div
90
+ # - locks the outermost container to exact pixel dims so it can't grow
91
+ _NO_RESIZE_CSS = """\
92
+ /* ── resizable=False overrides ─────────────────────────────── */
93
+ /* Hide all resize handles rendered by the widget JS */
94
+ div[style*="nwse-resize"],
95
+ div[title="Drag to resize"],
96
+ div[title="Drag to resize figure"] {{
97
+ display: none !important;
98
+ }}
99
+ /* Remove any bottom-right padding that was reserved for the handle */
100
+ #widget-root > div {{
101
+ padding-bottom: 0 !important;
102
+ padding-right: 0 !important;
103
+ }}
104
+ """
105
+
106
+ _PAGE_TEMPLATE = """\
107
+ <!DOCTYPE html>
108
+ <html>
109
+ <head>
110
+ <meta charset="utf-8"/>
111
+ <style>
112
+ html, body {{
113
+ margin: 0;
114
+ padding: 0;
115
+ background: transparent;
116
+ overflow: hidden;
117
+ /* Size the document exactly to the widget so scrollHeight == widget height */
118
+ width: {width}px;
119
+ height: {height}px;
120
+ }}
121
+ #widget-root {{
122
+ display: inline-block;
123
+ line-height: 0;
124
+ }}
125
+ {extra_css}\
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div id="widget-root"></div>
130
+ <script type="module">
131
+ const STATE = {state_json};
132
+ // Identifies this iframe to the parent-page anywidget bridge.
133
+ // null → no Pyodide wiring (plain notebook / static docs embed).
134
+ const FIG_ID = {fig_id_json};
135
+
136
+ // Loop-prevention flag: set while applying a parent-originated update so
137
+ // set() doesn't double-fire callbacks that save_changes() will also fire.
138
+ let _fromParent = false;
139
+ // Dirty flag: true only when model.set('event_json', ...) was called in the
140
+ // current transaction.
141
+ let _eventJsonDirty = false;
142
+
143
+ function makeModel(state) {{
144
+ const _data = Object.assign({{}}, state);
145
+ const _cbs = {{}};
146
+ const _anyCbs = [];
147
+ return {{
148
+ get(key) {{ return _data[key]; }},
149
+ set(key, val) {{
150
+ _data[key] = val;
151
+ if (key === 'event_json') _eventJsonDirty = true;
152
+ // Only fire synchronously when NOT processing an inbound awi_state
153
+ // message (save_changes() will fire listeners once in that path).
154
+ if (!_fromParent) {{
155
+ const ev = 'change:' + key;
156
+ if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}}
157
+ for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
158
+ }}
159
+ }},
160
+ save_changes() {{
161
+ for (const [ev, cbs] of Object.entries(_cbs))
162
+ for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
163
+ for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
164
+ // Forward interaction events to the parent-page Pyodide instance.
165
+ if (!_fromParent && FIG_ID && window.parent !== window && _eventJsonDirty) {{
166
+ _eventJsonDirty = false;
167
+ try {{
168
+ const ev = JSON.parse(_data.event_json || '{{}}');
169
+ if (ev && ev.source !== 'python') {{
170
+ window.parent.postMessage(
171
+ {{ type: 'awi_event', figId: FIG_ID, data: _data.event_json }}, '*');
172
+ }}
173
+ }} catch(_) {{}}
174
+ }} else {{
175
+ _eventJsonDirty = false;
176
+ }}
177
+ }},
178
+ on(event, cb) {{
179
+ if (event === "change") {{ _anyCbs.push(cb); return; }}
180
+ (_cbs[event] = _cbs[event] || []).push(cb);
181
+ }},
182
+ off(event, cb) {{
183
+ if (!event) {{ for (const k in _cbs) _cbs[k]=[]; _anyCbs.length=0; return; }}
184
+ if (_cbs[event]) _cbs[event] = _cbs[event].filter(c => c !== cb);
185
+ }},
186
+ get model() {{ return this; }},
187
+ }};
188
+ }}
189
+
190
+ const esmSource = {esm_json};
191
+ const blob = new Blob([esmSource], {{ type: "text/javascript" }});
192
+ const blobUrl = URL.createObjectURL(blob);
193
+ const el = document.getElementById("widget-root");
194
+ const model = makeModel(STATE);
195
+
196
+ import(blobUrl).then(mod => {{
197
+ const renderFn = mod.default?.render ?? mod.render;
198
+ if (typeof renderFn === "function") {{
199
+ renderFn({{ model, el }});
200
+ }} else {{
201
+ el.textContent = "ESM has no render() export";
202
+ }}
203
+ }}).catch(err => {{
204
+ el.textContent = "Widget load error: " + err;
205
+ console.error(err);
206
+ }});
207
+
208
+ // ── Inbound state updates from parent-page Pyodide ───────────────────────────
209
+ window.addEventListener('message', (e) => {{
210
+ if (!e.data || e.data.type !== 'awi_state') return;
211
+ _fromParent = true;
212
+ model.set(e.data.key, e.data.value);
213
+ model.save_changes();
214
+ _fromParent = false;
215
+ }});
216
+ </script>
217
+ </body>
218
+ </html>
219
+ """
220
+
221
+
222
+ def build_standalone_html(widget, *, resizable: bool = True,
223
+ fig_id: str | None = None) -> str:
224
+ """Return a self-contained HTML page that renders *widget* interactively.
225
+
226
+ Parameters
227
+ ----------
228
+ widget :
229
+ Any ``anywidget.AnyWidget`` subclass with ``_esm`` defined.
230
+ resizable : bool
231
+ When ``True`` (default) the widget's built-in resize handle is
232
+ preserved. When ``False`` the handle is hidden via CSS and the page
233
+ is sized exactly to the widget's natural dimensions.
234
+ fig_id : str or None
235
+ When provided, embedded as ``FIG_ID`` so the parent-page bridge
236
+ can route ``postMessage`` state updates to this iframe.
237
+ """
238
+ state = _widget_state(widget)
239
+
240
+ esm = getattr(widget, "_esm", "") or ""
241
+ if hasattr(esm, "read_text"):
242
+ esm = esm.read_text(encoding="utf-8")
243
+ esm = str(esm)
244
+
245
+ w, h = _widget_px(widget)
246
+ extra_css = _NO_RESIZE_CSS.format() if not resizable else ""
247
+
248
+ return _PAGE_TEMPLATE.format(
249
+ width=w,
250
+ height=h,
251
+ extra_css=extra_css,
252
+ state_json=json.dumps(state, default=str),
253
+ esm_json=json.dumps(esm),
254
+ fig_id_json=json.dumps(fig_id),
255
+ )
256
+
257
+
258
+ def repr_html_iframe(widget, *, resizable: bool = False,
259
+ max_width: int = MAX_NOTEBOOK_WIDTH,
260
+ max_height: int = 800) -> str:
261
+ """Return a centred, responsive ``<iframe srcdoc=...>`` embedding *widget*.
262
+
263
+ Parameters
264
+ ----------
265
+ widget :
266
+ Any ``anywidget.AnyWidget`` subclass.
267
+ resizable : bool
268
+ Passed to :func:`build_standalone_html`. Default ``False`` for
269
+ documentation embeds — hides the resize handle and sizes the iframe
270
+ exactly to the widget.
271
+ max_width : int
272
+ Maximum display width in pixels. Figures wider than this are scaled
273
+ down proportionally via ``transform:scale()`` so they never overflow
274
+ the notebook cell. Default ``MAX_NOTEBOOK_WIDTH`` (860 px).
275
+ max_height : int
276
+ Upper bound on iframe height in pixels (only used when
277
+ ``resizable=True``).
278
+ """
279
+ inner_html = build_standalone_html(widget, resizable=resizable)
280
+ escaped = escape(inner_html, quote=True)
281
+ uid = str(uuid4()).replace("-", "")
282
+
283
+ w, h = _widget_px(widget)
284
+
285
+ if not resizable:
286
+ # ── Responsive fixed-size embed ────────────────────────────────────
287
+ # The iframe always renders at its native resolution so the widget
288
+ # is pixel-perfect on wide screens. On narrower cells a CSS
289
+ # transform:scale() shrinks it proportionally — CSS transforms
290
+ # correctly route pointer events so interaction still works.
291
+ #
292
+ # The static scale (baked into the style attribute) renders correctly
293
+ # before JS runs. requestAnimationFrame defers the first JS
294
+ # measurement until after layout is complete so offsetWidth is
295
+ # always valid; the !avail guard prevents a not-yet-reflowed parent
296
+ # from collapsing the wrapper.
297
+ init_scale = min(1.0, max_width / w)
298
+ init_w = round(w * init_scale)
299
+ init_h = round(h * init_scale)
300
+ scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
301
+
302
+ js = (
303
+ f"(function(){{"
304
+ f"var wrap=document.getElementById('vw-{uid}'),"
305
+ f"ifr=wrap.querySelector('iframe'),"
306
+ f"nw={w},nh={h};"
307
+ f"function r(){{"
308
+ f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
309
+ f"if(!avail)return;"
310
+ f"var s=Math.min(1,avail/nw);"
311
+ f"wrap.style.width=Math.round(nw*s)+'px';"
312
+ f"wrap.style.height=Math.round(nh*s)+'px';"
313
+ f"ifr.style.transform='scale('+s+')';"
314
+ f"}}"
315
+ f"requestAnimationFrame(r);window.addEventListener('resize',r);"
316
+ f"}})()"
317
+ )
318
+
319
+ return (
320
+ f'<div style="display:block;text-align:center;line-height:0;margin:8px 0;">'
321
+ f'<div id="vw-{uid}" style="display:inline-block;overflow:hidden;'
322
+ f'position:relative;width:{init_w}px;height:{init_h}px;">'
323
+ f'<iframe srcdoc="{escaped}" frameborder="0" scrolling="no" '
324
+ f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
325
+ f'transform-origin:top left;transform:scale({scale_css});'
326
+ f'position:absolute;top:0;left:0;">'
327
+ f'</iframe>'
328
+ f'</div>'
329
+ f'<script>{js}</script>'
330
+ f'</div>'
331
+ )
332
+ else:
333
+ # ── Resizable embed (fills cell width, auto-sizes height) ──────────
334
+ return (
335
+ f'<iframe id="vw-{uid}" srcdoc="{escaped}" frameborder="0" '
336
+ f'style="width:100%;height:{h}px;border:none;overflow:hidden;" '
337
+ f'onload="setTimeout(function(){{'
338
+ f'var f=document.getElementById(\'vw-{uid}\');'
339
+ f'if(f&&f.contentWindow&&f.contentWindow.document.body){{'
340
+ f'f.style.height=Math.min('
341
+ f'f.contentWindow.document.body.scrollHeight+20,{max_height})+\'px\''
342
+ f'}}}},'
343
+ f'300)"></iframe>'
344
+ )
345
+