1010
1111__author__ = "Jonas Van Der Donckt, Jeroen Van Der Donckt, Emiel Deprost"
1212
13- import base64
14- import contextlib
15- import uuid
1613import warnings
1714from typing import List , Tuple
1815
1916import dash
2017import plotly .graph_objects as go
21- from jupyter_dash import JupyterDash
2218from plotly .basedatatypes import BaseFigure
2319from trace_updater import TraceUpdater
2420
3127from .figure_resampler_interface import AbstractFigureAggregator
3228from .utils import is_figure , is_fr
3329
30+ try :
31+ from .jupyter_dash_persistent_inline_output import JupyterDashPersistentInlineOutput
3432
35- class JupyterDashPersistentInlineOutput (JupyterDash ):
36- """Extension of the JupyterDash class to support the custom inline output for
37- ``FigureResampler`` figures.
38-
39- Specifically we embed a div in the notebook to display the figure inline.
40-
41- - In this div the figure is shown as an iframe when the server (of the dash app)
42- is alive.
43- - In this div the figure is shown as an image when the server (of the dash app)
44- is dead.
45-
46- As the HTML & javascript code is embedded in the notebook output, which is loaded
47- each time you open the notebook, the figure is always displayed (either as iframe
48- or just an image).
49- Hence, this extension enables to maintain always an output in the notebook.
50-
51- .. Note::
52- This subclass is only used when the mode is set to ``"inline_persistent"`` in
53- the :func:`FigureResampler.show_dash <plotly_resampler.figure_resampler.FigureResampler.show_dash>`
54- method. However, the mode should be passed as ``"inline"`` since this subclass
55- overwrites the inline behavior.
56-
57- .. Note::
58- This subclass utilizes the optional ``flask_cors`` package to detect whether the
59- server is alive or not.
60-
61- """
62-
63- def __init__ (self , * args , ** kwargs ):
64- super ().__init__ (* args , ** kwargs )
65-
66- self ._uid = str (uuid .uuid4 ()) # A new unique id for each app
67-
68- with contextlib .suppress (ImportWarning , ModuleNotFoundError ):
69- from flask_cors import cross_origin
70-
71- # Mimic the _alive_{token} endpoint but with cors
72- @self .server .route (f"/_is_alive_{ self ._uid } " , methods = ["GET" ])
73- @cross_origin (origin = ["*" ], allow_headers = ["Content-Type" ])
74- def broadcast_alive ():
75- return "Alive"
76-
77- def _display_inline_output (self , dashboard_url , width , height ):
78- """Display the dash app persistent inline in the notebook.
79-
80- The figure is displayed as an iframe in the notebook if the server is reachable,
81- otherwise as an image.
82- """
83- # TODO: check whether an error gets logged in case of crash
84- # TODO: add option to opt out of this
85- from IPython .display import display
86-
87- try :
88- import flask_cors # noqa: F401
89- except (ImportError , ModuleNotFoundError ):
90- warnings .warn (
91- "'flask_cors' is not installed. The persistent inline output will "
92- + " not be able to detect whether the server is alive or not."
93- )
94-
95- # Get the image from the dashboard and encode it as base64
96- fig = self .layout .children [0 ].figure # is stored there in the show_dash method
97- f_width = 1000 if fig .layout .width is None else fig .layout .width
98- fig_base64 = base64 .b64encode (
99- fig .to_image (format = "png" , width = f_width , scale = 1 , height = fig .layout .height )
100- ).decode ("utf8" )
101-
102- # The unique id of this app
103- # This id is used to couple the output in the notebook with this app
104- # A fetch request is performed to the _is_alive_{uid} endpoint to check if the
105- # app is still alive.
106- uid = self ._uid
107-
108- # The html (& javascript) code to display the app / figure
109- display (
110- {
111- "text/html" : f"""
112- <div id='PR_div__{ uid } '></div>
113- <script type='text/javascript'>
114- """
115- + """
116-
117- function setOutput(timeout) {
118- """
119- +
120- # Variables should be in local scope (in the closure)
121- f"""
122- var pr_div = document.getElementById('PR_div__{ uid } ');
123- var url = '{ dashboard_url } ';
124- var pr_img_src = 'data:image/png;base64, { fig_base64 } ';
125- var is_alive_suffix = '_is_alive_{ uid } ';
126- """
127- + """
128-
129- if (pr_div.firstChild) return // return if already loaded
130-
131- const controller = new AbortController();
132- const signal = controller.signal;
133-
134- return fetch(url + is_alive_suffix, {method: 'GET', signal: signal})
135- .then(response => response.text())
136- .then(data =>
137- {
138- if (data == "Alive") {
139- console.log("Server is alive");
140- iframeOutput(pr_div, url);
141- } else {
142- // I think this case will never occur because of CORS
143- console.log("Server is dead");
144- imageOutput(pr_div, pr_img_src);
145- }
146- }
147- )
148- .catch(error => {
149- console.log("Server is unreachable");
150- imageOutput(pr_div, pr_img_src);
151- })
152- }
153-
154- setOutput(350);
155-
156- function imageOutput(element, pr_img_src) {
157- console.log('Setting image');
158- var pr_img = document.createElement("img");
159- pr_img.setAttribute("src", pr_img_src)
160- pr_img.setAttribute("alt", 'Server unreachable - using image instead');
161- """
162- + f"""
163- pr_img.setAttribute("max-width", '{ width } ');
164- pr_img.setAttribute("max-height", '{ height } ');
165- pr_img.setAttribute("width", 'auto');
166- pr_img.setAttribute("height", 'auto');
167- """
168- + """
169- element.appendChild(pr_img);
170- }
171-
172- function iframeOutput(element, url) {
173- console.log('Setting iframe');
174- var pr_iframe = document.createElement("iframe");
175- pr_iframe.setAttribute("src", url);
176- pr_iframe.setAttribute("frameborder", '0');
177- pr_iframe.setAttribute("allowfullscreen", '');
178- """
179- + f"""
180- pr_iframe.setAttribute("width", '{ width } ');
181- pr_iframe.setAttribute("height", '{ height } ');
182- """
183- + """
184- element.appendChild(pr_iframe);
185- }
186- </script>
187- """
188- },
189- raw = True ,
190- clear = True ,
191- display_id = uid ,
192- )
193-
194- def _display_in_jupyter (self , dashboard_url , port , mode , width , height ):
195- """Override the display method to retain some output when displaying inline
196- in jupyter.
197- """
198- if mode == "inline" :
199- self ._display_inline_output (dashboard_url , width , height )
200- else :
201- super ()._display_in_jupyter (dashboard_url , port , mode , width , height )
33+ _jupyter_dash_installed = True
34+ except ImportError :
35+ _jupyter_dash_installed = False
20236
20337
20438class FigureResampler (AbstractFigureAggregator , go .Figure ):
@@ -353,9 +187,12 @@ def __init__(
353187 self .data [idx ].update (graph_dict ["data" ][idx ])
354188
355189 # The FigureResampler needs a dash app
356- self ._app : JupyterDash | dash .Dash | None = None
190+ self ._app : dash .Dash | None = None
357191 self ._port : int | None = None
358192 self ._host : str | None = None
193+ # Certain functions will be different when using persistent inline
194+ # (namely `show_dash` and `stop_callback`)
195+ self ._is_persistent_inline = False
359196
360197 def show_dash (
361198 self ,
@@ -439,13 +276,23 @@ def show_dash(
439276
440277 # 1. Construct the Dash app layout
441278 if mode == "inline_persistent" :
442- # Inline persistent mode: we display a static image of the figure when the
443- # app is not reachable
444- # Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
445279 mode = "inline"
446- app = JupyterDashPersistentInlineOutput ("local_app" )
280+ if _jupyter_dash_installed :
281+ # Inline persistent mode: we display a static image of the figure when the
282+ # app is not reachable
283+ # Note: this is the "inline" behavior of JupyterDashInlinePersistentOutput
284+ app = JupyterDashPersistentInlineOutput ("local_app" )
285+ self ._is_persistent_inline = True
286+ else :
287+ # If Jupyter Dash is not installed, inline persistent won't work and hence
288+ # we default to normal inline mode with a normal Dash app
289+ app = dash .Dash ("local_app" )
290+ warnings .warn (
291+ "'jupyter_dash' is not installed. The persistent inline mode will not work. Defaulting to standard inline mode."
292+ )
447293 else :
448- app = JupyterDash ("local_app" )
294+ # jupyter dash uses a normal Dash app as figure
295+ app = dash .Dash ("local_app" )
449296 app .layout = dash .html .Div (
450297 [
451298 dash .dcc .Graph (
@@ -458,13 +305,15 @@ def show_dash(
458305 )
459306 self .register_update_graph_callback (app , "resample-figure" , "trace-updater" )
460307
308+ height_param = "height" if self ._is_persistent_inline else "jupyter_height"
309+
461310 # 2. Run the app
462- if mode == "inline" and "height" not in kwargs :
311+ if mode == "inline" and height_param not in kwargs :
463312 # If app height is not specified -> re-use figure height for inline dash app
464313 # Note: default layout height is 450 (whereas default app height is 650)
465314 # See: https://plotly.com/python/reference/layout/#layout-height
466315 fig_height = self .layout .height if self .layout .height is not None else 450
467- kwargs ["height" ] = fig_height + 18
316+ kwargs [height_param ] = fig_height + 18
468317
469318 # kwargs take precedence over the show_dash_kwargs
470319 kwargs = {** self ._show_dash_kwargs , ** kwargs }
@@ -474,7 +323,11 @@ def show_dash(
474323 self ._host = kwargs .get ("host" , "127.0.0.1" )
475324 self ._port = kwargs .get ("port" , "8050" )
476325
477- app .run_server (mode = mode , ** kwargs )
326+ # function signature is slightly different for the Dash and JupyterDash implementations
327+ if self ._is_persistent_inline :
328+ app .run (mode = mode , ** kwargs )
329+ else :
330+ app .run (jupyter_mode = mode , ** kwargs )
478331
479332 def stop_server (self , warn : bool = True ):
480333 """Stop the running dash-app.
@@ -488,11 +341,19 @@ def stop_server(self, warn: bool = True):
488341 This only works if the dash-app was started with :func:`show_dash`.
489342 """
490343 if self ._app is not None :
491- old_server = self ._app ._server_threads .get ((self ._host , self ._port ))
344+ servers_dict = (
345+ self ._app ._server_threads
346+ if self ._is_persistent_inline
347+ else dash .jupyter_dash ._servers
348+ )
349+ old_server = servers_dict .get ((self ._host , self ._port ))
492350 if old_server :
493- old_server .kill ()
494- old_server .join ()
495- del self ._app ._server_threads [(self ._host , self ._port )]
351+ if self ._is_persistent_inline :
352+ old_server .kill ()
353+ old_server .join ()
354+ else :
355+ old_server .shutdown ()
356+ del servers_dict [(self ._host , self ._port )]
496357 elif warn :
497358 warnings .warn (
498359 "Could not stop the server, either the \n "
0 commit comments