Hooking up dcc.Slider to animated graph that uses a clientside_callback

0

I want to have a basic Dash app with an animated graph that you can Play/Pause, and change the time it displays with a dcc.Slider. I also need to have the current frame that the animation is exposed available so that I can also display additional graphs/datatables linked to the data currently being displayed, so I can’t just use a Plotly graph.

My data is large enough (think, ~3 columns, 50k rows, with ~60 points displayed at a time) that using server-side callbacks is very slow. So, instead I stream chunks of the data into a dcc.Store every n intervals, use another dcc.Store to keep track of the current animation frame, and then use a clientside callback to update the graph.

At the moment, I have the slider value set up to mirror the stored frame, so that it automatically updates. However, I’m having trouble figuring out a way to let the user move the value of the slider, and have the graph update accordingly. Since the dcc.Store with the frame is being updated in the clientside callback, it can’t be updated elsewhere. This is a simple version of what the code looks like now:

# -*- coding: utf-8 -*-

# Run this app with `python app.py` and
# visit http://127.0.0.1:8050/ in your web browser.

import pandas as pd
import numpy as np

import dash
import dash_core_components as dcc
import dash_bootstrap_components as dbc
import dash_html_components as html
from dash.dependencies import Input, Output, State
import dash_table # DataTable library

import plotly.express as px
import plotly.graph_objects as go


external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']

app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE])
df = pd.DataFrame(np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]),
                   columns=['x', 'y', 'time'])
# Initial trace/figure
trace = go.Scatter(x=df.x[:1], y=df.y[:1],
                        name='Location',
                        mode='markers'
                        )

fig = go.Figure(data=[trace])

app.layout = html.Div([
    # Animated graph
    dcc.Graph(
        id="graph", 
        figure=fig.update_layout(
            template='plotly_dark',
            paper_bgcolor= 'rgba(0, 0, 0, 0)',
        ),
    ),
    # Slider to control
    dcc.Slider(
        id="slider", 
        min=0, 
        max=len(df), 
        value=0
    ),
    # Buttons
    dbc.ButtonGroup([
        # Play
        dbc.Button(
            "Play",
            color='success', 
            id="dashPlay", 
            n_clicks=0
        ), 
        # Pause
        dbc.Button(
            "Pause", 
            color='danger',
            id="dashPause", 
            n_clicks=0
        ),],
    size="lg"),
    # Datatable
    dash_table.DataTable(
        id='table',
        columns=[{"name": i, "id": i} for i in df.columns], # Don't display seconds
        data=df.to_dict('records')
    ),  
    # Storing clientside data
    dcc.Store(     
        id='store', 
        data=dict(x=df.x, y=df.y) # Store figure data
        ),
    dcc.Store(
        id='frame', 
        data=0 # Store the current frame
        ),

    # Client-side animation interval
    dcc.Interval(
        id="animateInterval", 
        interval=2000, 
        n_intervals=0, 
        disabled=True
        ),
])

# Update the animation client-side
app.clientside_callback(
    """
    function (n_intervals, data, frame) {
        frame = frame % data.x.length;
        const end = Math.min((frame + 1), data.x.length);
        return [[{x: [data.x.slice(frame, end)], y: [data.y.slice(frame, end)]}, [0], 1], end, end]
    }
    """,
    [Output('graph', 'extendData'), Output('frame', 'data'), Output('slider', 'value')],
    [Input('animateInterval', 'n_intervals')], [State('store', 'data'), State('frame', 'data')]
)

# Handles updating the data tables
# --------------------------------
# Updates the currently displayed info to show the
# current second's data.
# https://community.plotly.com/t/update-a-dash-datatable-with-callbacks/21382/2
@app.callback(
    Output("table", "data"),
    Input("frame", "data"),
)
def updateTable(frame):
    # Once the animation has started...
    if frame:
        return (df.loc[df['time'] == frame]).to_dict('records')
    else:
        return (df.loc[df['time'] == 0]).to_dict('records')


# Handles pause/play
# ------------------
# Starts/pauses the 'interval' component, which starts/pauses 
# the animation.
@app.callback(
    Output("animateInterval","disabled"),
    Input("dashPlay", "n_clicks"),
    Input("dashPause", "n_clicks"),
    State("animateInterval","disabled"),
)
def pause_play(play_btn, pause_btn, disabled):
    # Check which button was pressed
    ctx = dash.callback_context
    if not ctx.triggered:
        return True
    else:
        button = ctx.triggered[0]['prop_id']
        if 'dashPlay' in button:
            return False
        else:
            return True

if __name__ == '__main__':
    app.run_server(debug=True)

As you can likely see, as of now there isn’t a way to change the value of the slider and have it change the frame of the animation.

My first thought was simply adding a context check in the clientside callback, and try and account for the slider changing there, but I couldn’t figure out how to do that with clientside_callback.

The only thing I can think of is having a second dcc.Store with the current frame, so there’s Store A and Store B. Then I’d have two callbacks, the clientside callback to update the animation, and another to read any changes in the slider values. Then, the structure would be:

Clientside Callback: In: Store A Out: Store B

Slider Callback: In: Store B Out: Store A

Then if the slider value changed (i.e., the user moved the slider), that would be reflected in Store A, and would update in the animation. Similarly, the slider would be updated by the clientside callback and move to an appropriate value. However, this seems to re-introduce waiting on the server into the animation for the graph, and it feels like this must be a solved problem with a better solution. I’d love any advice on the topic!