13
13
import warnings
14
14
from typing import Tuple , List
15
15
16
+ import uuid
17
+ import base64
16
18
import dash
17
19
import plotly .graph_objects as go
18
20
from dash import Dash
21
+ from flask_cors import cross_origin
19
22
from jupyter_dash import JupyterDash
20
23
from plotly .basedatatypes import BaseFigure
21
24
from trace_updater import TraceUpdater
25
28
from .utils import is_figure , is_fr
26
29
27
30
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
+
28
184
class FigureResampler (AbstractFigureAggregator , go .Figure ):
29
185
"""Data aggregation functionality for ``go.Figures``."""
30
186
@@ -84,7 +240,7 @@ def __init__(
84
240
verbose: bool, optional
85
241
Whether some verbose messages will be printed or not, by default False.
86
242
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.
88
244
Note that the passed kwargs will be take precedence over these defaults.
89
245
90
246
"""
@@ -109,7 +265,7 @@ def __init__(
109
265
f ._grid_ref = figure ._grid_ref
110
266
f .add_traces (figure .data )
111
267
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
113
269
):
114
270
# A figure as a dict, can be;
115
271
# - a plotly figure as a dict (after calling `fig.to_dict()`)
@@ -131,7 +287,9 @@ def __init__(
131
287
# A single trace dict or a list of traces
132
288
f .add_traces (figure )
133
289
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
+ )
135
293
136
294
super ().__init__ (
137
295
f ,
@@ -184,6 +342,15 @@ def show_dash(
184
342
web browser.
185
343
* ``"inline"``: The app will be displayed inline in the notebook output
186
344
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
+
187
354
* ``"jupyterlab"``: The app will be displayed in a dedicated tab in the
188
355
JupyterLab interface. Requires JupyterLab and the ``jupyterlab-dash``
189
356
extension.
@@ -206,10 +373,21 @@ def show_dash(
206
373
constructor via the ``show_dash_kwargs`` argument.
207
374
208
375
"""
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 } "
209
380
graph_properties = {} if graph_properties is None else graph_properties
210
381
assert "config" not in graph_properties .keys () # There is a param for config
211
382
# 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" )
213
391
app .layout = dash .html .Div (
214
392
[
215
393
dash .dcc .Graph (
@@ -223,10 +401,7 @@ def show_dash(
223
401
self .register_update_graph_callback (app , "resample-figure" , "trace-updater" )
224
402
225
403
# 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 :
230
405
# If app height is not specified -> re-use figure height for inline dash app
231
406
# Note: default layout height is 450 (whereas default app height is 650)
232
407
# See: https://plotly.com/python/reference/layout/#layout-height
0 commit comments