plotly-resampler icon indicating copy to clipboard operation
plotly-resampler copied to clipboard

Linking zoom between dynamically generated plots

Open Wout-S opened this issue 2 years ago • 3 comments

Hi,

I am making a dashboard where I want to visualize a large timeseries dataset. Currently the user can upload a datafile and plots are generated sorted by physical quantity (e.g. plot all temperatures together, plot all pressures together). This works perfectly with the resampler!

Now I want to add the functionality where the x-axis of all plots zoom when the user zooms in one of the plots. I created the following (non-)working example

from uuid import uuid4


from dash import dcc, ctx, ALL, MATCH, no_update
from dash import html
from dash_extensions.enrich import Dash, ServersideOutput, Output, Input, State, Trigger, DashProxy, TriggerTransform, ServersideOutputTransform, MultiplexerTransform 

import pandas as pd
import numpy as np

import plotly.io as pio
import plotly.graph_objects as go

from plotly_resampler import FigureResampler
from trace_updater import TraceUpdater

pio.renderers.default='browser'
pd.options.plotting.backend = "plotly"

external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = Dash(__name__,external_stylesheets=external_stylesheets)\
    
app = DashProxy(
    __name__,
    suppress_callback_exceptions=True,
    external_stylesheets=external_stylesheets,
    transforms=[ServersideOutputTransform(), TriggerTransform() ,MultiplexerTransform()],
)
app.layout = html.Div([
    html.Button("plot", id="btn-plot"),
    dcc.Store(id="store-temp"),
    html.Div(id='graph-container'),
    ])


@app.callback(

    ServersideOutput("store-temp", "data"),
    Input("btn-plot", "n_clicks"),
    )
def store_data(click):
    print('store data')
    n=10000
    x = np.arange(n)
    df=pd.DataFrame()
    y1 = (np.sin(x / 200) * 1 + np.random.randn(n) / 10 * 1 )
    y2 = (np.sin(x / 100) * 1 + np.random.randn(n) / 20 * 1 )
    df['y1']=y1
    df['y2']=y2
    return df

@app.callback(
    Output("graph-container", "children"),
    State("graph-container", "children"),
    Input("store-temp", "data"),
    prevent_initial_call=True
    )
def create_graphs(gc_children,df):
    print('creating graphs')
    gc_children = [] if gc_children is None else gc_children
    
    uid = str(uuid4())
    diff_container = html.Div(
        children=[
            # The graph and its needed components to serialize and update efficiently
            # Note: we also add a dcc.Store component, which will be used to link the
            #       server side cached FigureResampler object
            dcc.Graph(id={"type": "dynamic-graph", "index": uid}, figure=go.Figure()),
            dcc.Store(id={"type": "store", "index": uid}),
            dcc.Store(id={"type": "store-columns", "index": uid},data=['y1','y2']),
            TraceUpdater(id={"type": "dynamic-updater", "index": uid}, gdID=f"{uid}"),
            # This dcc.Interval components makes sure that the `construct_display_graph`
            # callback is fired once after these components are added to the session
            # its front-end
            dcc.Interval(
                id={"type": "interval", "index": uid}, max_intervals=1, interval=1
            ),
        ],
    )
    gc_children.append(diff_container)
    
    uid = str(uuid4())
    diff_container = html.Div(
        children=[
            dcc.Graph(id={"type": "dynamic-graph", "index": uid}, figure=go.Figure()),
            dcc.Store(id={"type": "store", "index": uid}),
            dcc.Store(id={"type": "store-columns", "index": uid},data=['y2', 'y1']),
            TraceUpdater(id={"type": "dynamic-updater", "index": uid}, gdID=f"{uid}"),
            dcc.Interval(
                id={"type": "interval", "index": uid}, max_intervals=1, interval=1
            ),
        ],
    )
    gc_children.append(diff_container)
        
    print('store data cb finish')
    return gc_children


@app.callback(
    ServersideOutput({"type": "store", "index": MATCH}, "data"),
    Output({"type": "dynamic-graph", "index": MATCH}, "figure"),
    State("store-temp", "data"),
    State({"type": "store-columns", "index": MATCH}, "data"),
    Trigger({"type": "interval", "index": MATCH}, "n_intervals"),
    prevent_initial_call=True,
)
def construct_display_graph(df,columns) -> FigureResampler:
    df2=df[columns]
    fr = FigureResampler(go.Figure(), verbose=True)
    for col in df2.columns:
        fr.add_trace(go.Scattergl(name=col, mode='lines'),hf_y=df2[col],hf_x=df2.index)
    fr.update_traces(connectgaps=True)
    return fr, fr


# @app.callback(
#     Output({"type": "dynamic-updater", "index": MATCH}, "updateData"),
#     Input({"type": "dynamic-graph", "index": MATCH}, "relayoutData"),
#     State({"type": "store", "index": MATCH}, "data"),
#     prevent_initial_call=True,
#     memoize=True,
# )
# def update_fig(relayoutdata: dict, fig: FigureResampler):
#     print(fig)
#     if fig is not None:
#         return fig.construct_update_data(relayoutdata)
#     return no_update

@app.callback(
    Output({"type": "dynamic-updater", "index": ALL}, "updateData"),
    Input({"type": "dynamic-graph", "index": ALL}, "relayoutData"),
    State({"type": "dynamic-graph", "index": ALL}, "id"),
    State({"type": "store", "index": ALL}, "data"),
    prevent_initial_call=True,
    memoize=True,
)
def update_fig(relayoutdata: list[dict],ids: list[dict], figs: list[FigureResampler]):
    figure_updated = ctx.triggered_id # get the id of the figure that triggered the callback
    triggered_index=ids.index(figure_updated) # get the index of the figure in the Input/Output lists of the callback
    # print(figure_updated)
    # print(relayoutdata)
    print(ids)
    # print('index : '+ str(triggered_index))
    zoomdata=dict(relayoutdata[triggered_index]) # get the relayoutdata of the figure that triggered the callback
    
    new_relayoutdata = []
    for i, data in enumerate(relayoutdata): # loop over current relayoutdata
        if i == triggered_index:
            new_relayoutdata.append(zoomdata) # keep relayoutdata of figure that triggered callback
        else:
            if 'xaxis.range[0]' in zoomdata:
                data = dict(relayoutdata[i])
                data['xaxis.range[0]'] = zoomdata ['xaxis.range[0]']
                data['xaxis.range[1]'] = zoomdata ['xaxis.range[1]']
                data['xaxis.autorange'] = False
                new_relayoutdata.append(data)
            else:
                new_relayoutdata.append(zoomdata)
    print(zoomdata)
    updatedata = []
    print(figs)
    for i,fig in enumerate(figs):
        if fig is None:
            return [no_update, no_update]
        else:
            updatedata.append(fig.construct_update_data(new_relayoutdata[i]))
    return updatedata



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

Like in the 11_sine_generator.py example plots are generated with a unique id. In the update_fig() callback I want to update all graphs if one of them updates. In the example (commented out in my code) MATCH is used. To get all FigureResampler object I replaced it with ALL. However, State({"type": "store", "index": ALL}, "data") produces a list with hashes(?) like ['b4de3743d91d23d6b85d2bcdd11cb531', '7d9efd7bb10eafd6cfcffe27b9ec566c']where State({"type": "store-columns", "index": MATCH}, "data") gives me the single FigureResampler object. With ALL I would expect a list of FigureResampler objects.

What am I missing here, how can I obtain the FigureResampler objects so I can modify them with construct_update_data()?

Wout-S avatar Nov 16 '22 17:11 Wout-S

Hi @Wout-S, very glad you like the functionality of plolty-resampler! 😄

Regarding your use-case:

I want to add the functionality where the x-axis of all plots zoom when the user zooms in one of the plots.

If I understand you correctly, you want to have shared x-axes over your multiple figure objects? This shared_axis functionality can be perfectly achieved via the make_subplots method from plotly.subplots 📝 docs. See code snippet below ⬇️

from plotly.subplots import make_subplots

# A figure object which has 3 rows 
fig = FigureResampler(make_subplots(
     rows=3,
     shared_xaxes=True
   )
)
...

So my question to you is, why would you not create multiple subplots within one figure object? Do users have the option to select a modality after an initial plot was made?

(fyi: take a look at the coarse-fine example, here the layout of two figures are linked.)

jonasvdd avatar Nov 16 '22 17:11 jonasvdd

Hi @jonasvdd,

Thanks for the fast reply! I think the subplots withe shared_xaxes is indeed what I want, thanks! I modified the example and this seems to work:

@app.callback(
    Output("graph-container", "children"),
    State("graph-container", "children"),
    Input("store-temp", "data"),
    prevent_initial_call=True
    )
def create_graphs(gc_children,df):
    print('creating graphs')
    gc_children = [] if gc_children is None else gc_children
    
    uid = str(uuid4())
    diff_container = html.Div(
        children=[
            # The graph and its needed components to serialize and update efficiently
            # Note: we also add a dcc.Store component, which will be used to link the
            #       server side cached FigureResampler object
            dcc.Graph(id={"type": "dynamic-graph", "index": uid}, figure=go.Figure()),
            dcc.Store(id={"type": "store", "index": uid}),
            dcc.Store(id={"type": "store-columns", "index": uid},data=[['y1','y2'],['y1','y2']]),
            TraceUpdater(id={"type": "dynamic-updater", "index": uid}, gdID=f"{uid}"),
            # This dcc.Interval components makes sure that the `construct_display_graph`
            # callback is fired once after these components are added to the session
            # its front-end
            dcc.Interval(
                id={"type": "interval", "index": uid}, max_intervals=1, interval=1
            ),
        ],
    )
    gc_children.append(diff_container)
        
    print('store data cb finish')
    return gc_children


@app.callback(
    ServersideOutput({"type": "store", "index": MATCH}, "data"),
    Output({"type": "dynamic-graph", "index": MATCH}, "figure"),
    State("store-temp", "data"),
    State({"type": "store-columns", "index": MATCH}, "data"),
    Trigger({"type": "interval", "index": MATCH}, "n_intervals"),
    prevent_initial_call=True,
)
def construct_display_graph(df,columnslist) -> FigureResampler:
    # df2=df[columns]
    fr = FigureResampler(go.Figure(), verbose=True)
    fr = FigureResampler(make_subplots(
        rows=len(columnslist),
        shared_xaxes=True
       )
    )
    for i, columns in enumerate(columnslist):
        df2=df[columns]
        for col in df2.columns: 
            fr.add_trace(go.Scattergl(name=col, mode='lines'),hf_y=df2[col],hf_x=df2.index, row=i+1, col=1 )
            fr.update_traces(connectgaps=True)
    return fr, fr

However, I might want to have te option to switch the shared x-axis, which is not possible anymore now. Is this the 'modality' you mean? (not familiar with the term) Also out of curiosity I would still like to understand what goes wrong in my initial example; not obtaining the FigureResampler objects seem to be the only problem there. If this would work, more advanced relayout 'gymnastics' would also be possible.

The coarse-fine example is aslo more similar to my initial code (no coincedence), where relayout data of one plot is used to modify another. The main difference is the pattern matching, part that behaves unexpected in my opinion.

Wout-S avatar Nov 16 '22 19:11 Wout-S

Hi @Wout-S,

I took a closer look at the example you provided, and the problem does not originate from plotly-resampler, but from the compatibility between ServerSideOutputTransform and the ALL wildcard; see https://github.com/thedirtyfew/dash-extensions/issues/222.

In your case, a list of ID strings are outputted instead of a figure. I think taking a closer look at how this ALL wildcard differs from the MATCH wildcard, might give you more answers.

I am really interested in the solution of this issue, so please keep me up to date! Have a nice weekend! Jonas

jonasvdd avatar Jan 20 '23 16:01 jonasvdd