5555from easydiffraction .display .theme import display_theme_colors_for_template
5656from easydiffraction .utils ._vendored .theme_detect import is_dark
5757from easydiffraction .utils .environment import FigureEmbedMode
58+ from easydiffraction .utils .environment import in_colab
5859from easydiffraction .utils .environment import in_jupyter
5960from easydiffraction .utils .environment import in_pycharm
6061from 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.
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">'
0 commit comments