Feature Request: HTML Embedded layout and dependency lookup for standalone HTML Dash Reports

Standalone HTML Report Examples

Here’s a zip with the examples I’ll talk about plus all related support files:
staging/stock_demo.zip at main · jwminton/staging · GitHub

I’ll try to highlight the important differences going from a simple save to the proposed dash standalone report. The examples use and cache public stock data. Its a little over the top but it stays the same across all examples and gives a nice rich sample to work from. I’ll go over the important changes from one example to the next ending with the standalone dash report. The main point of the zip are the 4 python file graph saving examples:
pure_plotly_save.py
simple_dash_save.py
interactive_clientside.py
interactive_dash_save.py

Plotly Support

Plotly.py supports the creation of standalone html reports by providing:
Library Access - Support library access in the form of minified plotly.js library that can be embedded into the html file.
HTML Expression - Offline plotly.py libraries for writing plotly figures to html

The plotly approach is to provide an api call that can be used to programmatically produce the standalone html plotly graph report:

fig = go.Figure(base_figure())
fig.write_html('plotly_save.html', include_plotlyjs=True)

The result is a single ~3Mb html file with script elements that embed the plotly library and that have the JSON serliazed plotly plot declarations included.

Dash Support

A Standalone HTML Dash report faces the same challenges.

Library Access - For Library Access, the problem is transitive closure over the set of scripts required to support the standalone dash application execution. Since we have full control over the plotly.py server, watching client/server interactions with dev tools will give the list of required support files. For me, in non-debug mode, this was my list (plus ramda):

async-dropdown.js
async-plotlyjs.js
dash_core_components-shared.js
dash_renderer.min.js
prop-types@15.7.2.min.js
react@16.13.0.min.js
async-graph.js
dash_core_components.min.js
dash_html_components.min.js
polyfill@7.8.7.min.js
react-dom@16.13.0.min.js
ramda.min.js

For the time being, since all I want is self contained behavior, I can make due with local relative file references. (See my post-processing/sed comments below for details.)

HTML Expression - Dash already knows how to write itself to JSON and HTML, in particular it knows how to serialize JSON expressions of clientside Dash application components and how their wired together relationships gets marshalled and unmarshalled. The problem here is the missing feature of being able to process embedded application information from the original page so that subsquent server calls are not required for clientside startup. The proposed changes allow this information to be embedded into the original html page.

Standalone HTML Report Implementation

From plotly to dash Save As…

This follows plotly’s suggested html save advice for a standard plotly figure:

\# Simple Pure Plotly graph save
fig = go.Figure(base_figure())
fig.write_html('pure_plotly_save.html', auto_open=False, include_plotlyjs=True)

Dash managed Save As…

With Dash, its possible to take the standard plotly ‘Save As…’ advice and make something somewhat more managed. Instead of a hardwired save based on server data, with dash you can send the rendered figure back to the plotly.py server and have it do the graph to html writing for you. This creates a server callback for a button label just to have a neutral dash target and sends the graph’s figure data as State to be saved:

app = Dash("Simple Dash Save")
app.layout = Div([
    Graph(id='graph', figure=base_figure()),
    Button(id='save', children='Save'),
])

@app.callback(Output('save', 'children'),
	      Input('save', 'n_clicks'),
	      State('graph', 'figure'),
	      prevent_initial_call=True)
def save_graph(n_clicks, graph):
    html = '''
    <html>
	<head>
	    <script type="text/javascript">{plotlyjs}</script>
	</head>
	<body>
	    <h1>Dash Example Save</h1>
	   {div}
	</body>
    </html>
    '''.format(plotlyjs=get_plotlyjs(),
	       div=plot(graph, output_type='div', include_plotlyjs=False)
	       )
    with open('simple_dash_save.html', 'w') as f:
	f.write(html)
    return 'Save'

Computed Trace

In the next example there is a dropdown that allows for the selection of a computed trace to be added to the current figure. The new trace computation is done with a serverside callback:

@app.callback(Output('graph', 'figure'),
              [Input('dropdown', 'value'),
               Input('graph', 'restyleData')],
              State('graph', 'figure'),
              prevent_initial_call=True)
def computed_trace(selected_operation, rData, figure):
    trace_array = []
    for entry in figure['data']:
        if 'meta' not in entry:
            trace_array.append(entry)

    data_array = []
    for entry in trace_array:
        if 'visible' not in entry or entry['visible'] != 'legendonly':
            data_array.append(entry['y'])

    df = DataFrame(data_array)

    if selected_operation == 'median':
        sum_list = df.median(axis=0)
    elif selected_operation == 'mean':
        sum_list = df.mean(axis=0)
    elif selected_operation == 'min':
        sum_list = df.min(axis=0)
    elif selected_operation == 'max':
        sum_list = df.max(axis=0)
    else:
        sum_list = []

    if len(sum_list) > 0:
        trace_array.append({'x': trace_array[0]['x'],
                            'y': sum_list,
                            'name': selected_operation,
                            'line': {'color': 'purple'},
                            'meta': {'computed': True, }
                            })
    figure['data'] = trace_array
    return figure

Note that the graph that sent back to the server and saved now can also contain this computed trace that wasn’t originally there when the page was initially rendered.

Dash Standalone Report

Lastly, this is the standalone dash report. The save button, save function and computedGraph function have all been removed and a custom MyDash class is used to inject the layout and dependencies content:

class MyDash(Dash):
    # initial layout and dependency script element
    def layout_script_elements(self):
        return '''<script id="_dash-layout" type="application/json">{auto_layout}</script>
                <script id="_dash-dependencies" type="application/json">{auto_deps}</script>'''.format(
            auto_layout=self.serve_layout().response[0].decode("utf-8"),
            auto_deps=self.dependencies().response[0].decode("utf-8"))

    # Called by Dash/Flask during startup for root page definition
    def interpolate_index(self, **kwargs):
        local_scripts = """<script src="/assets/ramda.min.js"></script>"""
        return '''<!DOCTYPE html>
        <html>
            <head>
                <title>Plotly/Dash Demo</title>
            </head>
            <body>
             {app_entry}
                {config}
                {auto_layout}
                {local_scripts}
                {scripts}
                {renderer}
            </body>
        </html>'''.format(app_entry=kwargs.get('app_entry'),
                          auto_layout=self.layout_script_elements(),
                          config=kwargs.get('config'),
                          scripts=kwargs.get('scripts'),
                          local_scripts=local_scripts,
                          renderer=kwargs.get('renderer'),
                          )

A clientside function for the computed trace (using Ramda) is provided to replace the server call:

app.clientside_callback(
    """
    function computed_trace(aggregateOp, rData, fig) {
            
        const traceArray = R.compose(
            R.reject(R.prop('meta')),
        )(fig.data)
        
        const dataArray = R.compose(
            R.map(R.prop('y')),
            R.reject(R.propEq('visible', 'legendonly')),
        )(traceArray)
        
        const transposedData = R.transpose(dataArray)
        var sum_list = null
        switch (aggregateOp) {
            case 'min':
                sum_list = R.map(R.reduce(R.min, Infinity))(transposedData)
                break 
            case 'max':
                sum_list = R.map(R.reduce(R.max, -Infinity))(transposedData)
                break 
            case 'median':
                sum_list = R.map(R.median)(transposedData)
                break
            case 'mean':
                sum_list = R.map(R.mean)(transposedData)
                break
        }
        if (sum_list != null)
            traceArray.push({
                x: fig.data[0].x ,
                y: sum_list,
                line: { color: 'purple' },
                name: aggregateOp,
                meta: { computed: true },
            })
        // clientside, this must be a deep copy to avoid object identity issues. 
        const newFig = JSON.parse(JSON.stringify(fig));
        newFig.data = traceArray
        return newFig
    }
    """,
    Output('graph', "figure"),
    [Input('dropdown', 'value'),
     Input('graph', "restyleData"),
     ],
    State('graph', "figure"),
    prevent_initial_call=True
)

Rather than a save that happens on the server, I can save the html recieved on the client with curl/sed and make my standalone dash report.

The sed script rewrites script src file references to use ./lib equivalents instead and removes some fingerprinting to get back to original file names:

curl localhost:8050 | sed '/<script[ ]\+src\=/ {s#/_dash-component-suites/[^/]*/#./lib/#;s#/assets/#./lib/#;s#\.v[0-9m0-9_]*##;s#\?m=[0-9\.]*##}' > "$DEST_FILE" 

I now have a self contained Dash view on my embedded data that can be treated as a unit for archival or sharing with coworkers.