plotly / dash

Data Apps & Dashboards for Python. No JavaScript Required.
https://plotly.com/dash
MIT License
21.42k stars 2.06k forks source link

determine which input is triggering in callback #59

Closed havok2063 closed 5 years ago

havok2063 commented 7 years ago

Since only a single Output is allowed in a callback, and all Inputs must feed into it, how can we determine which input is being triggered during a callback? I have three time-series line plots that I want to cross-filter, and update, any time a range is selected in any of the other plots. For example, the range of Plot A should update when either the range of Plot B or Plot C is updated. But the callback collects the ranges from all plots simultaneously. Is there a way to get the id of which input was triggered during a callback?

Here is my code but it only updates with data from plotB, when it is triggered. When I update Plot C the datac variable is indeed updated but the data from plotB is also passed in again, so the callback function has no knowledge of which plot is actually triggering new input.

@app.callback(
    Output('plotA', 'figure'),
    [Input('plotB', 'relayoutData'),
     Input('plotC', 'relayoutData')])
def display_selected_data(data, datac):

    print('data', data)
    print('datac', datac)

    startx = 'xaxis.range[0]' in data if data else None
    endx = 'xaxis.range[1]' in data if data else None

   # define the new xrange
    if startx and endx:
        xrange = [data['xaxis.range[0]'], data['xaxis.range[1]']]
    elif startx and not endx:
        xrange = [data['xaxis.range[0]'], thedates.max()]
    elif not startx and endx:
        xrange = [thedates.min(), data['xaxis.range[1]']]
    else:
        xrange = None

    traces = [go.Scatter(
        x=thedates,
        y=mdf['uniqvisits'])]
    return {
        'data': traces,
        'layout': get_layout('Unique Visits', 'Date', 'Unique Visits', xrange=xrange)
    }  
pyenthu commented 7 years ago

Have I believe you will have to write three different call backs and use the beta feature of EVENT and STATE to provide input to the function to generate output.

havok2063 commented 7 years ago

Can you provide a bit more detail on what the EVENT and STATE features are and how to use them? I can't find any information in the documentation about them.

pyenthu commented 7 years ago

https://gist.github.com/chriddyp/e546a2a2c05df29c868e4cef57fb2105

On Jul 1, 2017 5:51 PM, "Brian Cherinka" notifications@github.com wrote:

Can you provide a bit more detail on what the EVENT and STATE features are and how to use them? I can't find any information in the documentation about them.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/plotly/dash/issues/59#issuecomment-312429236, or mute the thread https://github.com/notifications/unsubscribe-auth/AMct3xa2VyA-4x6T2xALNkdBVS3GEy_vks5sJjnTgaJpZM4OK1fi .

havok2063 commented 7 years ago

Thanks for the link, but this isn't entirely helpful. There's no description of what events and states are. I get what events are, i.e. similar to events in javascript, but what are states? In this example code, they look exactly like Inputs. What's the difference between an Input and a State and how do I use States? Can I use them to distinguish which Input triggers a callback?

alishobeiri commented 7 years ago

@havok2063 States are arguments passed to the function of the callback that won't trigger the callback itself. So changing the code you provided:

@app.callback(
    Output('plotA', 'figure'),
    [Input('plotB', 'relayoutData')],
    [State('plotC', 'relayoutData')])
def display_selected_data(data, datac):

In the above code only changes on the relayoutData of plotB would trigger the callback, changes to the layout of plotC would not trigger the callback itself. plotC's relayoutData will be passed into def display_selected_data as datac and can be accessed when the callback itself is triggered but layout changes on plotC will not directly trigger the callback.

In terms of what you're trying to do, it might be bit tough to figure out directly which input triggered the callback, but one thing you could do is to have two hidden html.P element that you tie to separate callbacks and when either plotB changes or plotC changes you set the value of the html.P element associated with either one to a known value - for example, you could set the element to hold the value of 1 if the plot has changed and a 0 otherwise. You could then pass the paragraph elements as inputs:

@app.callback(
    Output('plotA', 'figure'),
    [Input('pHolderPlotB', 'children')
     Input('pHolderPlotC', 'children'],
    [State('plotB', 'relayoutData'), State('plotC', 'relayoutData')])
def display_selected_data(bTrigger, cTrigger, data, datac):
    if(int(bTrigger) == 1):
       # do this 
   elif(int(cTrigger) == 1):
       # do that

And to reset the values of pHolderPlotB and pHolderPlotC after you have redrawn plotA you could create a third callback that will reset the values of both pHolderPlotB and pHolderPlotC back to 0 when plotA has changed.

There might be easier ways to do this but this is one that came to mind.

Let me know if my answer was hard to follow or if I could clarify something.

chriddyp commented 7 years ago

Great question @havok2063 !

Since only a single Output is allowed in a callback, and all Inputs must feed into it, how can we determine which input is being triggered during a callback?

This isn't possible in Dash.

One core concept with Dash is that the app is described entirely by its current state and not by the order of events. This concept makes its easier to reason about the app's logic and it forces the user interface to be consistent with exactly what you see on the screen (and not by the steps that were taken to get there).

I think that in most cases, the Dash app developer shouldn't need to know the order of events. If they do, it usually means that the component needs to be patched in a way to make it more stateful or the UI needs to be redesigned. I'll leave this issue open to invite examples of good UIs that are impossible to create without supporting "order-of-events".


I have three time-series line plots that I want to cross-filter, and update, any time a range is selected in any of the other plots.

In this case, our app logic shouldn't depend on the most recent action, it should depend on the current state of the other graphs. For example, if Graph A depends on Graph B and Graph C, the data should be filtered by the intersect of the regions selected or zoomed in B and C, not just the most recently zoomed.


Here is how I recommend doing crossfiltering for now. A few things to note: 1 - We're using selectedData instead of zoom data with relayoutData so that the viewer can see the whole picture and can easily edit their selection. 2 - We're styling the selected graph's points themselves to better indicate which points were selected. 3 - We're displaying the most recently "selectedData" by drawing a rectangle shape (documentation on shapes). This requires a recent version of dash-core-components: pip install dash-core-components==0.5.3. 4 - To keep the code DRY 🌴 we're generating the callback functions and calling the decorators directly. For more on generating functions and generators in Python, I recommend this SO answer/essay on decorators. 5 - You're free to customize the style of the selected points or of the non-selected points. In this case, we're dimming the non-selected points by setting opacity=0.1. 6 - The numbers displayed on top of the points are just the IDs of the points to help you better understand how filtering is working. In practice, we would probably hide these or display a more descriptive ID.

import dash
from dash.dependencies import Input, Output
import dash_core_components as dcc
import dash_html_components as html

import numpy as np
import pandas as pd

app = dash.Dash()

df = pd.DataFrame({
    'Column {}'.format(i): np.random.rand(50) + i*10
for i in range(6)})

app.layout = html.Div([
    html.Div(dcc.Graph(id='g1', selectedData={'points': [], 'range': None}), className="four columns"),
    html.Div(dcc.Graph(id='g2', selectedData={'points': [], 'range': None}), className="four columns"),
    html.Div(dcc.Graph(id='g3', selectedData={'points': [], 'range': None}), className="four columns"),
], className="row")

def highlight(x, y):
    def callback(*selectedDatas):

        index = df.index;
        for i, hover_data in enumerate(selectedDatas):
            selected_index = [
                p['customdata'] for p in selectedDatas[i]['points']
                if p['curveNumber'] == 0 # the first trace that includes all the data
            ]
            if len(selected_index) > 0:
                index = np.intersect1d(index, selected_index)

        dff = df.iloc[index, :]

        color = 'rgb(125, 58, 235)'

        trace_template = {
            'marker': {
                'color': color,
                'size': 12,
                'line': {'width': 0.5, 'color': 'white'}
            }
        }
        figure = {
            'data': [
                dict({
                    'x': df[x], 'y': df[y], 'text': df.index, 'customdata': df.index,
                    'mode': 'markers', 'opacity': 0.1
                }, **trace_template),
                dict({
                    'x': dff[x], 'y': dff[y], 'text': dff.index,
                    'mode': 'markers+text', 'textposition': 'top',
                }, **trace_template),
            ],
            'layout': {
                'margin': {'l': 20, 'r': 0, 'b': 20, 't': 5},
                'dragmode': 'select',
                'hovermode': 'closest',
                'showlegend': False
            }
        }

        shape = {
            'type': 'rect',
            'line': {
                'width': 1,
                'dash': 'dot',
                'color': 'darkgrey'
            }
        }
        if selectedDatas[0]['range']:
            figure['layout']['shapes'] = [dict({
                'x0': selectedDatas[0]['range']['x'][0],
                'x1': selectedDatas[0]['range']['x'][1],
                'y0': selectedDatas[0]['range']['y'][0],
                'y1': selectedDatas[0]['range']['y'][1]
            }, **shape)]
        else:
            figure['layout']['shapes'] = [dict({
                'type': 'rect',
                'x0': np.min(df[x]),
                'x1': np.max(df[x]),
                'y0': np.min(df[y]),
                'y1': np.max(df[y])
            }, **shape)]

        return figure

    return callback

app.css.append_css({"external_url": "https://codepen.io/chriddyp/pen/bWLwgP.css"})

app.callback(
    Output('g1', 'figure'),
    [Input('g1', 'selectedData'), Input('g2', 'selectedData'), Input('g3', 'selectedData')]
)(highlight('Column 0', 'Column 1'))

app.callback(
    Output('g2', 'figure'),
    [Input('g2', 'selectedData'), Input('g1', 'selectedData'), Input('g3', 'selectedData')]
)(highlight('Column 2', 'Column 3'))

app.callback(
    Output('g3', 'figure'),
    [Input('g3', 'selectedData'), Input('g1', 'selectedData'), Input('g2', 'selectedData')]
)(highlight('Column 4', 'Column 5'))

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

crossfiltering-recipe

glyg commented 7 years ago

Hi,

I have a somewhat related design issue with the single Output paradigm: Part of my app is a simple frame by frame player, so just: a backward button, a frame number input and a forward button like so:

| < | 00 | > |

Those inputs should then update a graph, showing the desired frame. The callback looks like:

@app.callback(
    Output('graph', 'figure')
    [Input('frame-num', 'value'),],
    events=[Event('backward-button', 'click'),
              Event('forward-button', 'click'),]
    )

How can I distinguish between the two events (to decrement or increment my frame number)?

Thanks for the good work!

pgalilea commented 6 years ago

@glyg I'm facing the same issue

jwhendy commented 6 years ago

@glyg I thinks this is key from @chriddyp 's comment above:

One core concept with Dash is that the app is described entirely by its current state and not by the order of events.

For your example, the frame to display is simply the frame_num - back_clicks + forward_clicks, right? Basically, where did we start and how many times did we increment/decrement via buttons? At least that's how I'm interpreting it.

app.layout = html.Div([
    html.Button('<', id='back_clicks', n_clicks=0),
    dcc.Input(id='frame_num', type='number', placeholder='starting frame'),
    html.Button('>', id='forward_clicks', n_clicks=0),
    html.Div(id='graph')])

@app.callback(
    Output('graph', 'children'),
    [Input('frame_num', 'value'),
    Input('back_clicks', 'n_clicks'),
    Input('forward_clicks', 'n_clicks')])
def cback(frame_num, back, forward):
    return frame_num - back + forward
glyg commented 6 years ago

Hi @jwhendy, thanks for the suggestion! I thought about it actually but was a bit worried about the behavior at boundaries: if you keep on clicking the back button when you already reached 0, you'd need to click forward as many times without seeing anything happening ....

jwhendy commented 6 years ago

@glyg ah, I get it now. I don't know dash well enough to solve that, unfortunately.

Indeed, my mind keeps thinking of separate callbacks per button (which would update the same output) or needing to do something inelegant like hide "helper variables" inside of a div somewhere.

chriddyp commented 6 years ago

This feature is being considered through the PrevInput or PrevState abstraction in https://github.com/plotly/dash-renderer/pull/25. I'm not sure when this work will be finished. If your organization or company would like to expedite this this work through a sponsorship please reach out.

ronscoder commented 6 years ago

This is going to be a problem for me, as my app will have buttons with id dynamically generated, and one output area. There may be work-around, but it would mean extra labour.