1313import warnings
1414from typing import Tuple , List
1515
16+ import uuid
17+ import base64
1618import dash
1719import plotly .graph_objects as go
1820from dash import Dash
21+ from flask_cors import cross_origin
1922from jupyter_dash import JupyterDash
2023from plotly .basedatatypes import BaseFigure
2124from trace_updater import TraceUpdater
2528from .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+
28184class 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