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
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
|
+
]
|
anyplotlib/_base_plot.py
ADDED
|
@@ -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
|
anyplotlib/_electron.py
ADDED
|
@@ -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
|
+
|