-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_repr_utils.py
More file actions
303 lines (270 loc) · 10.8 KB
/
_repr_utils.py
File metadata and controls
303 lines (270 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
"""
_repr_utils.py
==============
Produces a self-contained HTML page that renders anywidget Widgets
interactively without a live Jupyter kernel.
Strategy
--------
1. Serialise every synced traitlet value to a plain JSON dict.
2. Embed that dict and the widget's ``_esm`` source directly in the page.
3. Provide a minimal model shim (get/set/on/save_changes) so the ESM's
render() function works without any Jupyter comm infrastructure.
4. Import the ESM as a Blob URL and call render({ model, el }).
When resizable=False (the default for documentation) the resize handle is
hidden via CSS and the iframe is sized exactly to the widget's own dimensions,
producing a tight, centred embed with no dead space.
"""
from __future__ import annotations
import json
from html import escape
from uuid import uuid4
# Maximum display width (px) for the non-resizable notebook embed.
# Figures wider than this are scaled down proportionally via CSS transform.
# 860 px fits comfortably in a standard JupyterLab / VS Code notebook cell.
MAX_NOTEBOOK_WIDTH = 860
# ---------------------------------------------------------------------------
# Trait serialisation
# ---------------------------------------------------------------------------
def _widget_state(widget) -> dict:
"""Return a {name: value} dict of every synced traitlet."""
state: dict = {}
for name, trait in widget.traits(sync=True).items():
if name.startswith("_"):
continue
raw = getattr(widget, name)
if isinstance(raw, (bytes, bytearray)):
import base64
raw = {"buffer": base64.b64encode(raw).decode("ascii")}
state[name] = raw
return state
def _widget_px(widget) -> tuple[int, int]:
"""Return (width_px, height_px) for the widget's full rendered size.
These are the *outer* pixel dimensions of the widget's root DOM element,
including any padding the widget JS adds around the canvas grid.
"""
try:
kind = type(widget).__name__
if kind == "Figure":
# figure_esm.js: gridDiv has padding:8px on all sides → +16 each axis
return int(widget.fig_width) + 16, int(widget.fig_height) + 16
# Viewer1D / Viewer2D — the outerContainer has padding:10px
w = int(getattr(widget, "viewer_width", 480))
h = int(getattr(widget, "viewer_height", 256))
PAD = 20 # 10px padding each side
if kind == "Viewer2D":
# Add axis canvas gutters (AXIS_SIZE = 40 in viewer2d JS)
AXIS = 40
w += AXIS + PAD
h += AXIS + PAD
if getattr(widget, "histogram_visible", False):
h_gap = int(getattr(widget, "gap", 10))
h_hw = int(getattr(widget, "histogram_width", 120))
w += h_hw + h_gap
else:
# Viewer1D: PAD_L=58, PAD_R=12, PAD_T=12, PAD_B=36 + outer 10px pad each side
w += 58 + 12 + 20 # PAD_L + PAD_R + outer
h += 12 + 36 + 20 # PAD_T + PAD_B + outer
return w, h
except Exception:
return 560, 340
# ---------------------------------------------------------------------------
# HTML builder
# ---------------------------------------------------------------------------
# Extra CSS injected when resizable=False:
# - hides every resize-handle div
# - locks the outermost container to exact pixel dims so it can't grow
_NO_RESIZE_CSS = """\
/* ── resizable=False overrides ─────────────────────────────── */
/* Hide all resize handles rendered by the widget JS */
div[style*="nwse-resize"],
div[title="Drag to resize"],
div[title="Drag to resize figure"] {{
display: none !important;
}}
/* Remove any bottom-right padding that was reserved for the handle */
#widget-root > div {{
padding-bottom: 0 !important;
padding-right: 0 !important;
}}
"""
_PAGE_TEMPLATE = """\
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<style>
html, body {{
margin: 0;
padding: 0;
background: transparent;
overflow: hidden;
/* Size the document exactly to the widget so scrollHeight == widget height */
width: {width}px;
height: {height}px;
}}
#widget-root {{
display: inline-block;
line-height: 0;
}}
{extra_css}\
</style>
</head>
<body>
<div id="widget-root"></div>
<script type="module">
const STATE = {state_json};
function makeModel(state) {{
const _data = Object.assign({{}}, state);
const _cbs = {{}};
const _anyCbs = [];
return {{
get(key) {{ return _data[key]; }},
set(key, val) {{
_data[key] = val;
const ev = 'change:' + key;
if (_cbs[ev]) for (const cb of [..._cbs[ev]]) try {{ cb({{ new: val }}); }} catch(_) {{}}
for (const cb of [..._anyCbs]) try {{ cb(); }} catch(_) {{}}
}},
save_changes() {{
for (const [ev, cbs] of Object.entries(_cbs))
for (const cb of cbs) try {{ cb({{ new: _data[ev.slice(7)] }}); }} catch(_) {{}}
for (const cb of _anyCbs) try {{ cb(); }} catch(_) {{}}
}},
on(event, cb) {{
if (event === "change") {{ _anyCbs.push(cb); return; }}
(_cbs[event] = _cbs[event] || []).push(cb);
}},
off(event, cb) {{
if (!event) {{ for (const k in _cbs) _cbs[k]=[]; _anyCbs.length=0; return; }}
if (_cbs[event]) _cbs[event] = _cbs[event].filter(c => c !== cb);
}},
get model() {{ return this; }},
}};
}}
const esmSource = {esm_json};
const blob = new Blob([esmSource], {{ type: "text/javascript" }});
const blobUrl = URL.createObjectURL(blob);
const el = document.getElementById("widget-root");
const model = makeModel(STATE);
import(blobUrl).then(mod => {{
const renderFn = mod.default?.render ?? mod.render;
if (typeof renderFn === "function") {{
renderFn({{ model, el }});
}} else {{
el.textContent = "ESM has no render() export";
}}
}}).catch(err => {{
el.textContent = "Widget load error: " + err;
console.error(err);
}});
</script>
</body>
</html>
"""
def build_standalone_html(widget, *, resizable: bool = True) -> str:
"""Return a self-contained HTML page that renders *widget* interactively.
Parameters
----------
widget :
Any ``anywidget.AnyWidget`` subclass with ``_esm`` defined.
resizable : bool
When ``True`` (default) the widget's built-in resize handle is
preserved. When ``False`` the handle is hidden via CSS and the page
is sized exactly to the widget's natural dimensions.
"""
state = _widget_state(widget)
esm = getattr(widget, "_esm", "") or ""
if hasattr(esm, "read_text"):
esm = esm.read_text(encoding="utf-8")
esm = str(esm)
w, h = _widget_px(widget)
extra_css = _NO_RESIZE_CSS.format() if not resizable else ""
return _PAGE_TEMPLATE.format(
width=w,
height=h,
extra_css=extra_css,
state_json=json.dumps(state, default=str),
esm_json=json.dumps(esm),
)
def repr_html_iframe(widget, *, resizable: bool = False,
max_width: int = MAX_NOTEBOOK_WIDTH,
max_height: int = 800) -> str:
"""Return a centred, responsive ``<iframe srcdoc=...>`` embedding *widget*.
Parameters
----------
widget :
Any ``anywidget.AnyWidget`` subclass.
resizable : bool
Passed to :func:`build_standalone_html`. Default ``False`` for
documentation embeds — hides the resize handle and sizes the iframe
exactly to the widget.
max_width : int
Maximum display width in pixels. Figures wider than this are scaled
down proportionally via ``transform:scale()`` so they never overflow
the notebook cell. Default ``MAX_NOTEBOOK_WIDTH`` (860 px).
max_height : int
Upper bound on iframe height in pixels (only used when
``resizable=True``).
"""
inner_html = build_standalone_html(widget, resizable=resizable)
escaped = escape(inner_html, quote=True)
uid = str(uuid4()).replace("-", "")
w, h = _widget_px(widget)
if not resizable:
# ── Responsive fixed-size embed ────────────────────────────────────
# The iframe always renders at its native resolution so the widget
# is pixel-perfect on wide screens. On narrower cells a CSS
# transform:scale() shrinks it proportionally — CSS transforms
# correctly route pointer events so interaction still works.
#
# The static scale (baked into the style attribute) renders correctly
# before JS runs. requestAnimationFrame defers the first JS
# measurement until after layout is complete so offsetWidth is
# always valid; the !avail guard prevents a not-yet-reflowed parent
# from collapsing the wrapper.
init_scale = min(1.0, max_width / w)
init_w = round(w * init_scale)
init_h = round(h * init_scale)
scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
js = (
f"(function(){{"
f"var wrap=document.getElementById('vw-{uid}'),"
f"ifr=wrap.querySelector('iframe'),"
f"nw={w},nh={h};"
f"function r(){{"
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:0;"
f"if(!avail)return;"
f"var s=Math.min(1,avail/nw);"
f"wrap.style.width=Math.round(nw*s)+'px';"
f"wrap.style.height=Math.round(nh*s)+'px';"
f"ifr.style.transform='scale('+s+')';"
f"}}"
f"requestAnimationFrame(r);window.addEventListener('resize',r);"
f"}})()"
)
return (
f'<div style="display:block;text-align:center;line-height:0;margin:8px 0;">'
f'<div id="vw-{uid}" style="display:inline-block;overflow:hidden;'
f'position:relative;width:{init_w}px;height:{init_h}px;">'
f'<iframe srcdoc="{escaped}" frameborder="0" scrolling="no" '
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
f'transform-origin:top left;transform:scale({scale_css});'
f'position:absolute;top:0;left:0;">'
f'</iframe>'
f'</div>'
f'<script>{js}</script>'
f'</div>'
)
else:
# ── Resizable embed (fills cell width, auto-sizes height) ──────────
return (
f'<iframe id="vw-{uid}" srcdoc="{escaped}" frameborder="0" '
f'style="width:100%;height:{h}px;border:none;overflow:hidden;" '
f'onload="setTimeout(function(){{'
f'var f=document.getElementById(\'vw-{uid}\');'
f'if(f&&f.contentWindow&&f.contentWindow.document.body){{'
f'f.style.height=Math.min('
f'f.contentWindow.document.body.scrollHeight+20,{max_height})+\'px\''
f'}}}},'
f'300)"></iframe>'
)