Skip to content

Commit fc9da71

Browse files
committed
Render Colab figures with frame-local versioned assets
1 parent d074640 commit fc9da71

5 files changed

Lines changed: 334 additions & 15 deletions

File tree

‎docs/dev/issues/open/lowest_live-notebook-plotly-delivery-loader-vs-native-mimetype.md‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,34 @@ gap. Two ways to fix it:
3333
delivery. Cons: more moving parts; depends on the notebook being
3434
**trusted** for a reopened (not re-run) notebook to re-render.
3535

36-
**Current choice:** Option 2 (keeps the live-notebook extras). Revisit
37-
Option 1 if the loader proves fragile across notebook frontends or the
38-
maintenance cost outweighs the three behaviours.
36+
- **Colab transport — versioned frame-local assets.** Colab isolates
37+
every cell output in its own iframe, so the once-per-kernel browser
38+
state assumed by Option 2 cannot cross cells. In Colab only, every
39+
figure output loads the version-matched Plotly bundle and
40+
`ed-figures.js` from the published documentation assets in parallel
41+
(the loader only needs Plotly once it renders), then calls the same
42+
`renderSpec` entry point. `google.colab.output.pauseOutputUntil` holds
43+
the output frame until the figure — or a visible error message — is on
44+
screen. Concurrent outputs in one cell share frame-global loading
45+
promises, so each asset is fetched and parsed only once. An immutable,
46+
exact-release-tag jsDelivr URL is the fallback while a new
47+
documentation version is deploying or if that deployment fails. Dev
48+
builds intentionally use only `/dev/` to avoid mixing arbitrary local
49+
code with the loader from another revision. The browser cache avoids
50+
embedding or repeatedly transferring the runtime, while all
51+
loader-owned theme, resize, and legend behaviour remains intact.
52+
Documentation `SHARED` mode is selected first and is unaffected.
53+
54+
Saved Colab outputs still depend on the host restoring their dynamic
55+
scripts. Until that behaviour is confirmed manually, reopen-and-view
56+
without re-running is not guaranteed; re-run a cell if its restored
57+
output remains at the loading placeholder. An end-to-end test of a dev
58+
build likewise requires its matching loader to have reached `/dev/`.
59+
60+
**Current choice:** Option 2 for JupyterLab, with the frame-local asset
61+
transport for Colab. Revisit Option 1 if the loader proves fragile
62+
across notebook frontends or the maintenance cost outweighs the three
63+
behaviours.
3964

4065
**Depends on:** nothing.
4166

‎src/easydiffraction/display/plotters/assets/ed-figures.js‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/*
22
* Shared lazy loader for EasyDiffraction interactive figures.
33
*
4-
* Loaded once per docs page (mkdocs `extra_javascript`) and once per
5-
* kernel session in live notebooks (injected by `_show_figure`). Plotly
6-
* figures emitted as placeholders carry their spec as `application/json`;
4+
* Loaded once per docs page (mkdocs `extra_javascript`), once per
5+
* JupyterLab kernel session (injected by `_show_figure`), and by URL in
6+
* each isolated Google Colab output frame. Plotly figures emitted as
7+
* placeholders carry their spec as `application/json`;
78
* this loader renders each one lazily when it scrolls near the viewport
89
* (IntersectionObserver), behind a "Loading…" skeleton. It also
910
* centralizes the theme-sync, resize, and legend-toggle behavior that
@@ -383,15 +384,15 @@
383384

384385
function renderInto(figureEl, spec) {
385386
if (figureEl.getAttribute('data-ed-rendered') === 'true') {
386-
return;
387+
return Promise.resolve();
387388
}
388389
var target = figureEl.querySelector('.ed-figure-target') || figureEl;
389390
if (!spec || !target || !window.Plotly) {
390-
return;
391+
return Promise.resolve();
391392
}
392393
figureEl.setAttribute('data-ed-rendered', 'true');
393394
var config = spec.config || plotlyConfig();
394-
window.Plotly.newPlot(target, spec.data || [], spec.layout || {}, config).then(
395+
return window.Plotly.newPlot(target, spec.data || [], spec.layout || {}, config).then(
395396
function () {
396397
figureEl.classList.add('ed-figure--ready');
397398
// Hide the loading skeleton directly, so the figure does not
@@ -412,7 +413,7 @@
412413

413414
// Docs path: read the spec embedded in the placeholder's JSON script.
414415
function render(figureEl) {
415-
renderInto(figureEl, readSpec(figureEl));
416+
return renderInto(figureEl, readSpec(figureEl));
416417
}
417418

418419
// Live-notebook path: render a spec passed directly (via Javascript
@@ -421,10 +422,10 @@
421422
function renderSpec(targetId, spec) {
422423
var target = document.getElementById(targetId);
423424
if (!target) {
424-
return;
425+
return Promise.resolve();
425426
}
426427
var figureEl = target.closest('.ed-figure') || target;
427-
renderInto(figureEl, spec);
428+
return renderInto(figureEl, spec);
428429
}
429430

430431
function activate() {

‎src/easydiffraction/display/plotters/plotly.py‎

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,11 @@
5555
from easydiffraction.display.theme import display_theme_colors_for_template
5656
from easydiffraction.utils._vendored.theme_detect import is_dark
5757
from easydiffraction.utils.environment import FigureEmbedMode
58+
from easydiffraction.utils.environment import in_colab
5859
from easydiffraction.utils.environment import in_jupyter
5960
from easydiffraction.utils.environment import in_pycharm
6061
from easydiffraction.utils.environment import resolve_figure_embed_mode
62+
from easydiffraction.utils.utils import _get_version_for_url
6163

6264
# Live notebooks self-host the Plotly runtime and the shared figure
6365
# loader (both ship in the wheel) instead of fetching Plotly from a CDN.
@@ -66,6 +68,14 @@
6668
# attribute that resets with each new kernel process).
6769
_PLOTLY_RUNTIME_ASSET = 'vendor/plotly/plotly-cartesian.min.js'
6870
_FIGURE_LOADER_ASSET = 'assets/ed-figures.js'
71+
_DOCS_ASSET_BASE_URL = 'https://easyscience.github.io/diffraction-lib'
72+
_TAG_ASSET_CDN_BASE_URL = 'https://cdn.jsdelivr.net/gh/easyscience/diffraction-lib'
73+
_COLAB_PLOTLY_ASSET_PATH = 'assets/javascripts/vendor/plotly/plotly-cartesian.min.js'
74+
_COLAB_FIGURE_LOADER_ASSET_PATH = 'assets/javascripts/ed-figures.js'
75+
_SOURCE_PLOTLY_ASSET_PATH = (
76+
'src/easydiffraction/display/plotters/vendor/plotly/plotly-cartesian.min.js'
77+
)
78+
_SOURCE_FIGURE_LOADER_ASSET_PATH = 'src/easydiffraction/display/plotters/assets/ed-figures.js'
6979

7080

7181
@cache
@@ -1233,10 +1243,20 @@ def _show_figure(
12331243

12341244
# The docs site (SHARED) bakes a lazy placeholder into the page,
12351245
# which loads the runtime once and the loader scans for it.
1236-
if resolve_figure_embed_mode() is FigureEmbedMode.SHARED:
1246+
embed_mode = resolve_figure_embed_mode()
1247+
if embed_mode is FigureEmbedMode.SHARED:
12371248
display(HTML(self._serialize_html_shared(fig)))
12381249
return
12391250

1251+
# Every Colab cell output lives in a separate iframe, so a
1252+
# runtime injected by an earlier cell is not visible here. Load
1253+
# the same version-pinned assets used by the documentation site
1254+
# into each frame by URL. The browser cache avoids embedding or
1255+
# transferring the 1.4 MB runtime in every notebook output.
1256+
if in_colab():
1257+
display(HTML(self._serialize_html_colab(fig)))
1258+
return
1259+
12401260
# Live notebooks render through one HTML output: a target div
12411261
# plus a single <script> that, the first time per kernel
12421262
# session, carries the self-hosted Plotly bundle and the shared
@@ -1262,6 +1282,172 @@ def _show_figure(
12621282
)
12631283
display(HTML(self._wrap_html_figure(fig, target_html) + script))
12641284

1285+
@staticmethod
1286+
def _colab_asset_urls() -> tuple[tuple[str, ...], tuple[str, ...]]:
1287+
"""Return version-matched Plotly and loader URL candidates."""
1288+
version = _get_version_for_url()
1289+
root = f'{_DOCS_ASSET_BASE_URL}/{version}'
1290+
plotly_urls = [f'{root}/{_COLAB_PLOTLY_ASSET_PATH}']
1291+
loader_urls = [f'{root}/{_COLAB_FIGURE_LOADER_ASSET_PATH}']
1292+
1293+
# A release wheel can reach PyPI before its documentation deploy
1294+
# finishes. The git tag already exists at that point, so use its
1295+
# immutable jsDelivr snapshot as an exact-version fallback. Dev
1296+
# builds deliberately have no branch fallback: loading main's
1297+
# loader into arbitrary development code could create version
1298+
# skew that is harder to diagnose than a visible loading error.
1299+
if version != 'dev':
1300+
tag_root = f'{_TAG_ASSET_CDN_BASE_URL}@v{version}'
1301+
plotly_urls.append(f'{tag_root}/{_SOURCE_PLOTLY_ASSET_PATH}')
1302+
loader_urls.append(f'{tag_root}/{_SOURCE_FIGURE_LOADER_ASSET_PATH}')
1303+
1304+
return tuple(plotly_urls), tuple(loader_urls)
1305+
1306+
@staticmethod
1307+
def _loading_skeleton_html(height: int, *, overlay: bool = False) -> str:
1308+
"""
1309+
Return the shared loading placeholder for a figure.
1310+
1311+
The docs site styles ``.ed-figure-skeleton`` from its
1312+
stylesheet, so only the height is inlined there; inlining more
1313+
would silently shadow later stylesheet edits. ``overlay`` is for
1314+
hosts with no stylesheet (Colab), where the placeholder must
1315+
center its own text and sit above the plot target.
1316+
"""
1317+
styles = [f'height: {height}px']
1318+
if overlay:
1319+
styles.extend((
1320+
'display: flex',
1321+
'align-items: center',
1322+
'justify-content: center',
1323+
'font-size: 0.75rem',
1324+
'position: absolute',
1325+
'inset: 0',
1326+
'z-index: 1',
1327+
))
1328+
return f'<div class="ed-figure-skeleton" style="{"; ".join(styles)}">Loading plot…</div>'
1329+
1330+
@classmethod
1331+
def _serialize_html_colab(cls, fig: object) -> str:
1332+
"""
1333+
Serialize one independently renderable Google Colab output.
1334+
1335+
Colab isolates every cell output in its own iframe. The output
1336+
therefore loads the version-matched runtime and shared figure
1337+
loader by URL, then renders only after both scripts are ready.
1338+
Runtime bytes are never embedded in the notebook output.
1339+
1340+
Parameters
1341+
----------
1342+
fig : object
1343+
A :class:`plotly.graph_objects.Figure` to serialize.
1344+
1345+
Returns
1346+
-------
1347+
str
1348+
HTML containing the figure spec and frame-local bootstrap.
1349+
"""
1350+
plot_id = f'ed-fig-{uuid.uuid4().hex}'
1351+
height = cls._figure_height(fig)
1352+
plotly_urls, loader_urls = cls._colab_asset_urls()
1353+
target_html = (
1354+
'<div class="ed-figure" data-ed-figure="plotly" '
1355+
'style="position: relative">'
1356+
f'{cls._loading_skeleton_html(height, overlay=True)}'
1357+
f'<div class="ed-figure-target" id="{plot_id}" '
1358+
f'style="min-height: {height}px"></div>'
1359+
'</div>'
1360+
)
1361+
script = f"""
1362+
<script type="text/javascript">
1363+
(function () {{
1364+
window.__edAssetPromises = window.__edAssetPromises || {{}};
1365+
1366+
function loadScript(urls, ready) {{
1367+
if (ready()) {{
1368+
return Promise.resolve();
1369+
}}
1370+
var cacheKey = urls.join('|');
1371+
if (window.__edAssetPromises[cacheKey]) {{
1372+
return window.__edAssetPromises[cacheKey];
1373+
}}
1374+
1375+
function tryUrl(index) {{
1376+
if (index >= urls.length) {{
1377+
return Promise.reject(
1378+
new Error('Unable to load EasyDiffraction plot asset: ' + urls.join(', '))
1379+
);
1380+
}}
1381+
return new Promise(function (resolve, reject) {{
1382+
var script = document.createElement('script');
1383+
script.src = urls[index];
1384+
script.async = false;
1385+
script.onload = function () {{
1386+
if (ready()) {{
1387+
resolve();
1388+
return;
1389+
}}
1390+
script.remove();
1391+
tryUrl(index + 1).then(resolve, reject);
1392+
}};
1393+
script.onerror = function () {{
1394+
script.remove();
1395+
tryUrl(index + 1).then(resolve, reject);
1396+
}};
1397+
document.head.appendChild(script);
1398+
}});
1399+
}}
1400+
1401+
var loading = tryUrl(0);
1402+
window.__edAssetPromises[cacheKey] = loading;
1403+
loading.catch(function () {{
1404+
delete window.__edAssetPromises[cacheKey];
1405+
}});
1406+
return loading;
1407+
}}
1408+
1409+
// The loader does not touch Plotly until it renders, so both assets
1410+
// can travel together: one round trip instead of two per frame.
1411+
var rendering = Promise.all([
1412+
loadScript({json.dumps(plotly_urls)}, function () {{
1413+
return Boolean(window.Plotly);
1414+
}}),
1415+
loadScript({json.dumps(loader_urls)}, function () {{
1416+
return Boolean(window.edFigures && window.edFigures.renderSpec);
1417+
}}),
1418+
]).then(function () {{
1419+
return window.edFigures.renderSpec(
1420+
{json.dumps(plot_id)},
1421+
{cls._figure_spec_json(fig)}
1422+
);
1423+
}});
1424+
1425+
var settled = rendering.catch(function (error) {{
1426+
console.error(error);
1427+
var target = document.getElementById({json.dumps(plot_id)});
1428+
var figure = target && (target.closest('.ed-figure') || target);
1429+
var skeleton = figure && figure.querySelector('.ed-figure-skeleton');
1430+
if (skeleton) {{
1431+
skeleton.textContent = 'Unable to load interactive plot. Re-run the cell to retry.';
1432+
}}
1433+
}});
1434+
1435+
// Hand Colab the settled promise, so the output frame is released
1436+
// only once the plot (or its error message) is on screen, and Colab
1437+
// never receives a rejected promise of ours.
1438+
if (
1439+
window.google &&
1440+
window.google.colab &&
1441+
window.google.colab.output &&
1442+
typeof window.google.colab.output.pauseOutputUntil === 'function'
1443+
) {{
1444+
window.google.colab.output.pauseOutputUntil(settled);
1445+
}}
1446+
}})();
1447+
</script>
1448+
""".strip()
1449+
return cls._wrap_html_figure(fig, target_html) + script
1450+
12651451
@classmethod
12661452
def _live_runtime_bootstrap_js(cls) -> str:
12671453
"""
@@ -1400,8 +1586,7 @@ def _serialize_html_shared(cls, fig: object) -> str:
14001586
height = cls._figure_height(fig)
14011587
html_fig = (
14021588
'<div class="ed-figure" data-ed-figure="plotly">'
1403-
f'<div class="ed-figure-skeleton" style="height: {height}px">'
1404-
'Loading plot…</div>'
1589+
f'{cls._loading_skeleton_html(height)}'
14051590
f'<div class="ed-figure-target" id="{plot_id}" '
14061591
f'style="min-height: {height}px"></div>'
14071592
'<script type="application/json" class="ed-figure-spec">'

‎tests/unit/easydiffraction/display/plotters/test_plotly.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ def test_shared_loader_owns_theme_resize_and_legend_behaviour():
279279
assert 'window.edFigures.watchTheme = watchTheme;' in loader
280280
assert 'window.edFigures.watchResize = watchResize;' in loader
281281
assert 'window.edFigures.installLegendToggle = installLegendToggle;' in loader
282+
# Colab waits for rendering before releasing its output frame.
283+
assert 'return window.Plotly.newPlot(' in loader
284+
assert 'return renderInto(figureEl, spec);' in loader
282285

283286

284287
def test_serialize_html_standalone_embeds_loader_once():

0 commit comments

Comments
 (0)