Skip to content

Commit 5b7bcd0

Browse files
authored
Merge pull request #105 from predict-idlab/hacking_notebook_output
🤖 hack together output retention in notebooks
2 parents c8de84b + 95cf3c2 commit 5b7bcd0

File tree

7 files changed

+378
-74
lines changed

7 files changed

+378
-74
lines changed

plotly_resampler/figure_resampler/figure_resampler.py

+183-8
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
import warnings
1414
from typing import Tuple, List
1515

16+
import uuid
17+
import base64
1618
import dash
1719
import plotly.graph_objects as go
1820
from dash import Dash
21+
from flask_cors import cross_origin
1922
from jupyter_dash import JupyterDash
2023
from plotly.basedatatypes import BaseFigure
2124
from trace_updater import TraceUpdater
@@ -25,6 +28,159 @@
2528
from .utils import is_figure, is_fr
2629

2730

31+
class JupyterDashPersistentInlineOutput(JupyterDash):
32+
"""Extension of the JupyterDash class to support the custom inline output for
33+
``FigureResampler`` figures.
34+
35+
Specifically we embed a div in the notebook to display the figure inline.
36+
37+
- In this div the figure is shown as an iframe when the server (of the dash app)
38+
is alive.
39+
- In this div the figure is shown as an image when the server (of the dash app)
40+
is dead.
41+
42+
As the HTML & javascript code is embedded in the notebook output, which is loaded
43+
each time you open the notebook, the figure is always displayed (either as iframe
44+
or just an image).
45+
Hence, this extension enables to maintain always an output in the notebook.
46+
47+
.. Note::
48+
This subclass is only used when the mode is set to ``"inline_persistent"`` in
49+
the :func:`FigureResampler.show_dash <plotly_resampler.figure_resampler.FigureResampler.show_dash>`
50+
method. However, the mode should be passed as ``"inline"`` since this subclass
51+
overwrites the inline behavior.
52+
"""
53+
54+
def __init__(self, *args, **kwargs):
55+
super().__init__(*args, **kwargs)
56+
57+
self._uid = str(uuid.uuid4()) # A new unique id for each app
58+
59+
# Mimic the _alive_{token} endpoint but with cors
60+
@self.server.route(f"/_is_alive_{self._uid}", methods=["GET"])
61+
@cross_origin(origin=["*"], allow_headers=["Content-Type"])
62+
def broadcast_alive():
63+
return "Alive"
64+
65+
def _display_inline_output(self, dashboard_url, width, height):
66+
"""Display the dash app persistent inline in the notebook.
67+
68+
The figure is displayed as an iframe in the notebook if the server is reachable,
69+
otherwise as an image.
70+
"""
71+
# TODO: check whether an error gets logged in case of crash
72+
# TODO: add option to opt out of this
73+
from IPython.display import display
74+
75+
# Get the image from the dashboard and encode it as base64
76+
fig = self.layout.children[0].figure # is stored there in the show_dash method
77+
f_width = 1000 if fig.layout.width is None else fig.layout.width
78+
fig_base64 = base64.b64encode(
79+
fig.to_image(format="png", width=f_width, scale=1, height=fig.layout.height)
80+
).decode("utf8")
81+
82+
# The unique id of this app
83+
# This id is used to couple the output in the notebook with this app
84+
# A fetch request is performed to the _is_alive_{uid} endpoint to check if the
85+
# app is still alive.
86+
uid = self._uid
87+
88+
# The html (& javascript) code to display the app / figure
89+
display(
90+
{
91+
"text/html": f"""
92+
<div id='PR_div__{uid}'></div>
93+
<script type='text/javascript'>
94+
"""
95+
+ """
96+
97+
function setOutput(timeout) {
98+
"""
99+
+
100+
# Variables should be in local scope (in the closure)
101+
f"""
102+
var pr_div = document.getElementById('PR_div__{uid}');
103+
var url = '{dashboard_url}';
104+
var pr_img_src = 'data:image/png;base64, {fig_base64}';
105+
var is_alive_suffix = '_is_alive_{uid}';
106+
"""
107+
+ """
108+
109+
if (pr_div.firstChild) return // return if already loaded
110+
111+
const controller = new AbortController();
112+
const signal = controller.signal;
113+
114+
return fetch(url + is_alive_suffix, {method: 'GET', signal: signal})
115+
.then(response => response.text())
116+
.then(data =>
117+
{
118+
if (data == "Alive") {
119+
console.log("Server is alive");
120+
iframeOutput(pr_div, url);
121+
} else {
122+
// I think this case will never occur because of CORS
123+
console.log("Server is dead");
124+
imageOutput(pr_div, pr_img_src);
125+
}
126+
}
127+
)
128+
.catch(error => {
129+
console.log("Server is unreachable");
130+
imageOutput(pr_div, pr_img_src);
131+
})
132+
}
133+
134+
setOutput(350);
135+
136+
function imageOutput(element, pr_img_src) {
137+
console.log('Setting image');
138+
var pr_img = document.createElement("img");
139+
pr_img.setAttribute("src", pr_img_src)
140+
pr_img.setAttribute("alt", 'Server unreachable - using image instead');
141+
"""
142+
+ f"""
143+
pr_img.setAttribute("max-width", '{width}');
144+
pr_img.setAttribute("max-height", '{height}');
145+
pr_img.setAttribute("width", 'auto');
146+
pr_img.setAttribute("height", 'auto');
147+
"""
148+
+ """
149+
element.appendChild(pr_img);
150+
}
151+
152+
function iframeOutput(element, url) {
153+
console.log('Setting iframe');
154+
var pr_iframe = document.createElement("iframe");
155+
pr_iframe.setAttribute("src", url);
156+
pr_iframe.setAttribute("frameborder", '0');
157+
pr_iframe.setAttribute("allowfullscreen", '');
158+
"""
159+
+ f"""
160+
pr_iframe.setAttribute("width", '{width}');
161+
pr_iframe.setAttribute("height", '{height}');
162+
"""
163+
+ """
164+
element.appendChild(pr_iframe);
165+
}
166+
</script>
167+
"""
168+
},
169+
raw=True,
170+
clear=True,
171+
display_id=uid,
172+
)
173+
174+
def _display_in_jupyter(self, dashboard_url, port, mode, width, height):
175+
"""Override the display method to retain some output when displaying inline
176+
in jupyter.
177+
"""
178+
if mode == "inline":
179+
self._display_inline_output(dashboard_url, width, height)
180+
else:
181+
super()._display_in_jupyter(dashboard_url, port, mode, width, height)
182+
183+
28184
class FigureResampler(AbstractFigureAggregator, go.Figure):
29185
"""Data aggregation functionality for ``go.Figures``."""
30186

@@ -84,7 +240,7 @@ def __init__(
84240
verbose: bool, optional
85241
Whether some verbose messages will be printed or not, by default False.
86242
show_dash_kwargs: dict, optional
87-
A dict that will be used as default kwargs for the :func:`show_dash` method.
243+
A dict that will be used as default kwargs for the :func:`show_dash` method.
88244
Note that the passed kwargs will be take precedence over these defaults.
89245
90246
"""
@@ -109,7 +265,7 @@ def __init__(
109265
f._grid_ref = figure._grid_ref
110266
f.add_traces(figure.data)
111267
elif isinstance(figure, dict) and (
112-
"data" in figure or "layout" in figure # or "frames" in figure # TODO
268+
"data" in figure or "layout" in figure # or "frames" in figure # TODO
113269
):
114270
# A figure as a dict, can be;
115271
# - a plotly figure as a dict (after calling `fig.to_dict()`)
@@ -131,7 +287,9 @@ def __init__(
131287
# A single trace dict or a list of traces
132288
f.add_traces(figure)
133289

134-
self._show_dash_kwargs = show_dash_kwargs if show_dash_kwargs is not None else {}
290+
self._show_dash_kwargs = (
291+
show_dash_kwargs if show_dash_kwargs is not None else {}
292+
)
135293

136294
super().__init__(
137295
f,
@@ -184,6 +342,15 @@ def show_dash(
184342
web browser.
185343
* ``"inline"``: The app will be displayed inline in the notebook output
186344
cell in an iframe.
345+
* ``"inline_persistent"``: The app will be displayed inline in the
346+
notebook output cell in an iframe, if the app is not reachable a static
347+
image of the figure is shown. Hence this is a persistent version of the
348+
``"inline"`` mode, allowing users to see a static figure in other
349+
environments, browsers, etc.
350+
351+
.. note::
352+
This mode requires the ``kaleido`` package.
353+
187354
* ``"jupyterlab"``: The app will be displayed in a dedicated tab in the
188355
JupyterLab interface. Requires JupyterLab and the ``jupyterlab-dash``
189356
extension.
@@ -206,10 +373,21 @@ def show_dash(
206373
constructor via the ``show_dash_kwargs`` argument.
207374
208375
"""
376+
available_modes = ["external", "inline", "inline_persistent", "jupyterlab"]
377+
assert (
378+
mode is None or mode in available_modes
379+
), f"mode must be one of {available_modes}"
209380
graph_properties = {} if graph_properties is None else graph_properties
210381
assert "config" not in graph_properties.keys() # There is a param for config
211382
# 1. Construct the Dash app layout
212-
app = JupyterDash("local_app")
383+
if mode == "inline_persistent":
384+
# Inline persistent mode: we display a static image of the figure when the
385+
# app is not reachable
386+
# Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
387+
mode = "inline"
388+
app = JupyterDashPersistentInlineOutput("local_app")
389+
else:
390+
app = JupyterDash("local_app")
213391
app.layout = dash.html.Div(
214392
[
215393
dash.dcc.Graph(
@@ -223,10 +401,7 @@ def show_dash(
223401
self.register_update_graph_callback(app, "resample-figure", "trace-updater")
224402

225403
# 2. Run the app
226-
if (
227-
mode == "inline"
228-
and "height" not in kwargs
229-
):
404+
if mode == "inline" and "height" not in kwargs:
230405
# If app height is not specified -> re-use figure height for inline dash app
231406
# Note: default layout height is 450 (whereas default app height is 650)
232407
# See: https://plotly.com/python/reference/layout/#layout-height

0 commit comments

Comments
 (0)