Plotly Dash - A Python Data Visualisation Framework

(The code for this app is published on our github)

This post accompanies this post on our sister blog, medium.com/@thishere.

Dash is a new Python framework from the data visualisation team at plot.ly. Plotly has been our favourite data vis tool for a while now. It's great for making interactive charts and hosting them online. Dash is the next step. It's built on top of Plotl.js, React and Flask and allows you to create analytical web applications in pure Python.

Below is a simple Dash app that we created when doing some research into some influencer marketing campaigns. The left hand drop-down menu allows you to select which metric you would like displayed on the y-axis. The central drop-down allows you to select which campaigns to display. And the right hand drop-down allows you to filter the selection by which country the influencer is based in. The horizontal plotted line is the average for all posts in that particular campaign. You can also click on the points to display the post and its statistics below. The photo and the Instagram handle are hyperlinked if you would like to navigate to the Instagram page.

This Dash is hosted as a web application on Heroku.

Dash apps are made up of two parts. The first part is the "layout" which describes the appearance of the app. This is made particularly easy for those of us who aren't front end devs. The code below is the layout of the dashboard above. The dropdown menus and graph are rendered using the dash_core_components library. Standard HTML elements are rendered using the dash_html_components library. We will use the ids of these components to update them on the fly in the second part of the application.

app.layout = html.Div([
                html.Div([
                    html.Div([
                        html.H6('Choose Metric:', className = "gs-header gs-text-header padded")
                        ],
                        style={'width': '25%', 'display': 'inline-block'}
                    ),
                    html.Div([
                        html.H6('Choose Campaign:', className = "gs-header gs-text-header padded")
                        ],
                        style={'width': '50%', 'display': 'inline-block'}
                    ),
                    html.Div([
                        html.H6('Choose Country:', className = "gs-header gs-text-header padded")
                        ],
                        style={'width': '25%', 'display': 'inline-block'}
                    ),
                ]),
                html.Div([
                    html.Div([
                        dcc.Dropdown(
                            id='yaxis-column',
                            options=[{'label': i, 'value': i} for i in available_indicators],
                            value='ENGAGEMENT RATIO')
                        ],
                        style={'width': '25%', 'display': 'inline-block'}
                    ),
                    html.Div([
                        dcc.Dropdown(
                            id='campaign-selection',
                            options=[{'label': i, 'value': i} for i in campaigns],
                            multi=True,
                            value=campaigns)
                        ],
                        style={'width': '50%', 'display': 'inline-block'}
                    ),
                    html.Div([
                        dcc.Dropdown(
                            id='country-selection',
                            value='All')
                        ],
                        style={'width': '25%', 'display': 'inline-block'}
                    )
                ]),
                html.Div([
                    html.Div([
                        dcc.Graph(id='main-graph')
                        ],
                        style={'width': '100%', 'display': 'inline-block'}
                    ),
                ],
                className='row'
                ),
                html.Div([
                    html.Div([
                        html.H6('Selected Post:', className = "gs-header gs-text-header padded")
                        ],
                        style={'width': '100%', 'display': 'inline-block'}
                    )
                ]),
                html.Div([
                    html.Div([
                        html.A([
                            html.Img(id='selected-post-image',
                                    style={'position': 'relative',
                                           'height' : '200',
                                           'width' : '200',
                                           'left' : '30%',
                                           'top' : '30'})
                            ],
                            id='selected-post-hyperlink',
                            target='_blank'
                        )
                    ],style={'width': '50%', 'display': 'inline-block'}
                    ),
                    html.Div([
                        html.A([
                            html.H5(id='selected-ig-handle')
                            ],
                            id='selected-ig-handle-hyperlink',
                            target='_blank',
                            className='column',
                            style={'position': 'relative',
                                   'left' : '27%',
                                   'top' : '5'}
                        ),
                        html.H5(id='selected-likes',
                                className='column',
                                style={'position': 'relative',
                                       'left' : '25%',
                                       'top' : '5'}
                        ),
                        html.H5(id='selected-comments',
                                className='column',
                                style={'position': 'relative',
                                       'left' : '25%',
                                       'top' : '5'}
                        ),
                        html.H5(id='selected-engagement-rate',
                                className='column',
                                style={'position': 'relative',
                                       'left' : '25%',
                                       'top' : '5'}
                        ),
                        html.H5(id='selected-engagement-ratio',
                                className='column',
                                style={'position': 'relative',
                                       'left' : '25%',
                                       'top' : '5'}
                        )
                    ],style={'width': '25%', 'display': 'inline-block','position': 'relative'}
                    )
                ]),
                html.Div([
                    html.Div([
                        html.H6('', className = "gs-header gs-text-header padded")
                        ],
                        style={'width': '100%', 'display': 'inline-block','margin-top' : '45'}
                    )
                ])
            ])

The interactive part of the app is a collection of functions with the inputs and outputs described declaratively through the app.callback decorator. Our first example below shows the function that populates the country-selection dropdown menu. We take the campaigns selected in the campaign-selection dropdown menu as our input, filter out the countries that are not in those campaigns, and then output the list of countries to the country-selection dropdown.

@app.callback(
    Output('country-selection', 'options'),
    [dash.dependencies.Input('campaign-selection', 'value')])
def populate_country_selection(campaign_selection):
    country_list = ["All"]
    for campaign in campaign_selection:
        campaign_df = df[df['CAMPAIGN'] == campaign]
        campaign_country_list = campaign_df['COUNTRY'].unique()
        country_list.extend([i for i in campaign_country_list])
    return [{'label': i, 'value': i} for i in country_list]

The central functionality of this Dash is the scatter chart. This function takes four inputs: the three dropdown menus and clickData from the main graph itself. The clickData is used to determine when a point on the graph has been selected. To achieve this we use the customdata field when creating the scatter chart. We can then get the post id of a point on the graph when it is clicked on using clickData["points"][0]["customdata"].


@app.callback(
    dash.dependencies.Output('main-graph', 'figure'),
    [dash.dependencies.Input('yaxis-column', 'value'),
     dash.dependencies.Input('campaign-selection', 'value'),
     dash.dependencies.Input('country-selection', 'value'),
     Input('main-graph', 'clickData')])
def update_graph(yaxis_column_name,
                 campaign_selection,
                 country_selection,
                 clickData):

    if country_selection == 'All':
        df_country = df
    else:
        df_country = df[df['COUNTRY'] == country_selection]

    if campaign_selection:
        if clickData:
            try:
                post_id = clickData["points"][0]["customdata"]
            except KeyError:
                post_id = None
        else:
            post_id = None

        df_campaigns = []
        for campaign in campaign_selection:
            df_campaigns.append(df_country[df_country['CAMPAIGN'] == campaign])

        df_post = pd.DataFrame()
        for df_campaign in df_campaigns:
            if post_id in df_campaign['POST ID'].values:
                df_post = df_campaign[df_campaign['POST ID'] == post_id]
                break

        max_y = 0.0
        min_y = 100000000000000000.0

        data = []
        colour_count = 0
        for df_campaign in df_campaigns:
            data.append(go.Scatter( x=df_campaign["DATE POSTED"],
                                    y=df_campaign[yaxis_column_name], 
                                    customdata = df_campaign["POST ID"],
                                    mode='markers',
                                    name=campaign_selection[colour_count],
                                    marker=dict(
                                        color=colour_dict[campaign_selection[colour_count]],
                                    ),
                                    text=df_campaign['IG HANDLE'],
                                    opacity=0.6
                                ))
            try:
                max_x = max(df_campaign["DATE POSTED"])
                min_x = min(df_campaign["DATE POSTED"])
                average = df_campaign[yaxis_column_name].mean()
                data.append(go.Scatter( x=[min_x,max_x],
                                        y=[average,average],
                                        mode='lines',
                                        name=campaign_selection[colour_count],
                                        marker=dict(
                                            color=colour_dict[campaign_selection[colour_count]],
                                        ),
                                        opacity=0.6,
                                        showlegend=False
                                    ))
                if df_campaign[yaxis_column_name].max() > max_y: max_y = df_campaign[yaxis_column_name].max()
                if df_campaign[yaxis_column_name].min() < min_y: min_y = df_campaign[yaxis_column_name].min()
            except ValueError:
                pass
            colour_count+=1
        
        y_axis_range = [0.0,1.1*max_y]

        if not df_post.empty:
            data.append(go.Scatter( x=df_post["DATE POSTED"],
                                    y=df_post[yaxis_column_name],
                                    customdata = df_post["POST ID"],
                                    mode='markers',
                                    name='Selected Post',
                                    marker=dict(color='rgb(0,0,0)'),
                                    opacity=1.0,
                                    showlegend=False))
        figure = {    'data': data,
                    'layout': go.Layout(title='',
                                        showlegend=True,
                                        hovermode='closest',
                                        legend=dict(orientation="h",
                                                    x=0,
                                                    y=100),
                                        yaxis=dict(range=y_axis_range))
                 }
        return figure
    else:
        return None

The selected post image and stats are determined by a series of functions that take the campaign-selectioncountry-selection and clickData as inputs and then output the computed value to the relevant html component.
@app.callback(
    Output('selected-post-image', 'src'),
    [Input('main-graph', 'clickData'),
    dash.dependencies.Input('campaign-selection', 'value'),
     dash.dependencies.Input('country-selection', 'value')])
def display_selected_post_image(clickData,
                                 campaign_selection,
                                 country_selection):
    if clickData:
        try:
            post_id = clickData["points"][0]["customdata"]
        except KeyError:
            return None
        if country_selection == 'All':
            df_country = df
        else:
            df_country = df[df['COUNTRY'] == country_selection]
        if campaign_selection:
            df_campaigns = [df_country[df_country['CAMPAIGN'] == i] for i in campaign_selection]
            for df_campaign in df_campaigns:
                if post_id in df_campaign['POST ID'].values:
                    return df_campaign[df_campaign['POST ID'] == post_id]["IMAGE"].unique()
    return None

The Dash user guide is a great place to start if you fancy playing around with Dash. We think it is a big step forward for data visualisations in Python. Feel free to download the code for this app from our github page.

Comments

Post a Comment