Created
March 5, 2026 20:36
-
-
Save kerrickstaley/988f3b5792cf4d3f7e0e6da983cbc982 to your computer and use it in GitHub Desktop.
Tool for sharing a Plotly graph via image and/or web-page hosted on GitHub Gist
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import base64 | |
| import concurrent.futures | |
| import json | |
| import subprocess | |
| import tempfile | |
| from pathlib import Path | |
| from IPython.display import Javascript, display | |
| def _copy_text_to_clipboard(text: str): | |
| display( | |
| Javascript( | |
| f''' | |
| (async () => {{ | |
| try {{ | |
| await navigator.clipboard.writeText({json.dumps(text)}); | |
| }} catch (e) {{ | |
| console.error(e); | |
| }} | |
| }})(); | |
| ''' | |
| ) | |
| ) | |
| def _copy_png_to_clipboard(png_b64: str): | |
| display( | |
| Javascript( | |
| f''' | |
| (async () => {{ | |
| try {{ | |
| const bytes = Uint8Array.from(atob({json.dumps(png_b64)}), c => c.charCodeAt(0)); | |
| const blob = new Blob([bytes], {{ type: 'image/png' }}); | |
| await navigator.clipboard.write([new ClipboardItem({{ 'image/png': blob }})]); | |
| }} catch (e) {{ | |
| console.error(e); | |
| }} | |
| }})(); | |
| ''' | |
| ) | |
| ) | |
| def _copy_png_and_text_to_clipboard(png_b64: str, text: str): | |
| display( | |
| Javascript( | |
| f''' | |
| (async () => {{ | |
| try {{ | |
| const bytes = Uint8Array.from(atob({json.dumps(png_b64)}), c => c.charCodeAt(0)); | |
| const imageBlob = new Blob([bytes], {{ type: 'image/png' }}); | |
| const textBlob = new Blob([{json.dumps(text)}], {{ type: 'text/plain' }}); | |
| const item = new ClipboardItem({{ | |
| 'image/png': imageBlob, | |
| 'text/plain': textBlob | |
| }}); | |
| await navigator.clipboard.write([item]); | |
| }} catch (e) {{ | |
| console.error(e); | |
| }} | |
| }})(); | |
| ''' | |
| ) | |
| ) | |
| def _plotly_html_to_gisthost_url(fig_html: str, filename: str = 'index.html') -> str: | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| filepath = Path(tmpdir) / filename | |
| filepath.write_text(fig_html) | |
| result = subprocess.run( | |
| ['gh', 'gist', 'create', str(filepath)], | |
| capture_output=True, | |
| text=True, | |
| check=True, | |
| ) | |
| gist_url = result.stdout.strip().splitlines()[-1] | |
| gist_id = gist_url.rstrip('/').split('/')[-1] | |
| return f'https://gisthost.github.io/?{gist_id}\n' | |
| def copyable_figure(fig, **kwargs): | |
| '''Return widget buttons for copying PNG and Githost URL for a Plotly figure.''' | |
| import ipywidgets as widgets | |
| fig_html = fig.to_html(include_plotlyjs='cdn', full_html=True) | |
| png_kwargs = dict(kwargs) | |
| png_kwargs.setdefault('scale', 2.0) | |
| png_b64 = None | |
| gisthost_url = None | |
| copy_png_button = widgets.Button(description='Render PNG') | |
| copy_githost_button = widgets.Button(description='Create Gist') | |
| copy_both_button = widgets.Button(description='Render PNG + Gist') | |
| def _gen_png(): | |
| nonlocal png_b64 | |
| if png_b64 is None: | |
| png_bytes = fig.to_image(format='png', **png_kwargs) | |
| png_b64 = base64.b64encode(png_bytes).decode() | |
| def _gen_gist(): | |
| nonlocal gisthost_url | |
| if gisthost_url is None: | |
| gisthost_url = _plotly_html_to_gisthost_url(fig_html) | |
| def _update_button_state(busy): | |
| copy_png_button.description = 'Copy PNG' if png_b64 else 'Render PNG' | |
| copy_githost_button.description = 'Copy Gist URL' if gisthost_url else 'Create Gist' | |
| if png_b64 and gisthost_url: | |
| copy_both_button.description = 'Copy PNG + URL' | |
| elif png_b64: | |
| copy_both_button.description = 'Create Gist' | |
| elif gisthost_url: | |
| copy_both_button.description = 'Render PNG' | |
| else: | |
| copy_both_button.description = 'Render PNG + Gist' | |
| for b in (copy_png_button, copy_githost_button, copy_both_button): | |
| b.disabled = busy | |
| def on_copy_png_click(_): | |
| try: | |
| if png_b64 is None: | |
| _update_button_state(busy=True) | |
| copy_png_button.description = 'Rendering PNG...' | |
| _gen_png() | |
| else: | |
| _copy_png_to_clipboard(png_b64) | |
| copy_png_button.description = 'Copied!' | |
| return | |
| except Exception as exc: | |
| copy_png_button.description = f'Failed: {str(exc)[:30]}' | |
| return | |
| _update_button_state(busy=False) | |
| def on_copy_githost_click(_): | |
| try: | |
| if gisthost_url is None: | |
| _update_button_state(busy=True) | |
| copy_githost_button.description = 'Creating gist...' | |
| _gen_gist() | |
| else: | |
| _copy_text_to_clipboard(gisthost_url) | |
| copy_githost_button.description = 'Copied!' | |
| return | |
| except subprocess.CalledProcessError as exc: | |
| error_text = exc.stderr.strip() or exc.stdout.strip() or str(exc) | |
| copy_githost_button.description = f'Failed: {error_text[:30]}' | |
| return | |
| except Exception as exc: | |
| copy_githost_button.description = f'Failed: {str(exc)[:30]}' | |
| return | |
| _update_button_state(busy=False) | |
| def on_copy_both_click(_): | |
| try: | |
| if png_b64 is None or gisthost_url is None: | |
| _update_button_state(busy=True) | |
| need_png = png_b64 is None | |
| need_gist = gisthost_url is None | |
| if need_png and need_gist: | |
| copy_both_button.description = 'Rendering + creating gist...' | |
| elif need_png: | |
| copy_both_button.description = 'Rendering PNG...' | |
| else: | |
| copy_both_button.description = 'Creating gist...' | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: | |
| futures = [] | |
| if need_png: | |
| futures.append(executor.submit(_gen_png)) | |
| if need_gist: | |
| futures.append(executor.submit(_gen_gist)) | |
| for f in futures: | |
| f.result() | |
| else: | |
| _copy_png_and_text_to_clipboard(png_b64, gisthost_url) | |
| copy_both_button.description = 'Copied!' | |
| return | |
| except subprocess.CalledProcessError as exc: | |
| error_text = exc.stderr.strip() or exc.stdout.strip() or str(exc) | |
| copy_both_button.description = f'Failed: {error_text[:30]}' | |
| return | |
| except Exception as exc: | |
| copy_both_button.description = f'Failed: {str(exc)[:30]}' | |
| return | |
| _update_button_state(busy=False) | |
| copy_png_button.on_click(on_copy_png_click) | |
| copy_githost_button.on_click(on_copy_githost_click) | |
| copy_both_button.on_click(on_copy_both_click) | |
| return widgets.HBox([copy_png_button, copy_githost_button, copy_both_button]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment