Clientside callback to save interactive HTML figure

I have a callback in my Dash app that grabs a large figure (scatter_3d plot with many animation frames) that was generated in a previous background callback and saves it as an interactive HTML file. That’s all working fine, however the callback to save the figure is slow since it has to make a round trip to the server with lots of data.

I’d like to switch this to use a clientside callback to save the figure instead, but I can’t find any documentation on how to do something like that using the plotly.js side of things. The output HTML file from the existing callback is ~80MB, otherwise I would just precompute and put it in a clientside store that I can download from as described in the docs.

For reference, here’s what the callback I have now looks like (figure-download is a dcc.Download and 3d-plot is the dcc.Graph):

@callback(
    Output("figure-download", "data"),
    Input("download-button", "n_clicks"),
    State("3d-plot", "figure"),
    prevent_initial_call=True
)
def download(n_clicks, fig_data):
    if fig_data and n_clicks:
        fig = go.Figure(fig_data)
        return dict(content=fig.to_html(), filename="figure.html")

Hi,

I think that if you want to do it clientside, then you’ll have to code your own .to_html() function. The best option to me seems to find the already generated HTML content of #3d-plot and copy it.

clientside_callback(
	"""function() {

		// Get the figure
		var fig = document.getElementById("3d-plot");

		// Get the HTML ? 
		var html_content = fig.innerHTML();

		// Next : create a blob or a link with your content
		// so that it triggers a download. keywords for search: javascript blob content download


		return dash_clientside.no_update;
	}
	""",
	Input("download-button", "n_clicks"),
    Input("download-button", "n_clicks"),
)

(code not tested).

Another solution is to store your figure content in a global variable


# At creation time, you can just save the content in a global variable, or on the Disk
fig_data = ...

# Then you get rid of the State (which is causing you yo upload 80MB of data)
# But the user will still have to download again 80MB of data.

@callback(
    Output("figure-download", "data"),
    Input("download-button", "n_clicks"),
    prevent_initial_call=True
)
def download(n_clicks):
    if n_clicks:
        fig = go.Figure(fig_data)
        return dict(content=fig.to_html(), filename="figure.html")

This solution however is not safe for multiple users / multiple figure data, and will not work if your Dash app is deployed with gunicorn and multiple workers/threads, etc. I think the clientside solution is better.

Hope it helps,

Thanks for the input!

I tried grabbing the HTML of the figure itself as you suggested, but unfortunately it looks like the data is not actually stored directly in that HTML, so the .to_html() call must be doing something different. You can see this if you inspect the plot using the dev tools in the browser. So the output HTML I got looked like my figure, just the data was not there so nothing was actually displayed.

I think your point is correct about having to build up the HTML myself if I want to do a clientside callback. However, it may take a bit more work to pull the data from wherever it is stored in the page and successfully assemble it into a standalone figure. Maybe I can poke around in the repo and see what .to_html() is actually doing.

I have another page that is doing server-side caching of data in Redis, so I could do something similar to make your second solution work for multiple users by just caching the HTML content at figure creation time and using that in my callback instead.

Yes, if you have already a Redis cache server, then that’s maybe the faster and easiest to setup :slight_smile:
I