Les événements dans Dash¶

Il est bien de pouvoir interagir dans son tableau de bord. Cela peut être le fait de cliquer sur un bouton pour afficher un autre graphe, choisir les champs dans un menu, indiquer si on préfère une échelle linéaire ou logarithmique, etc.

Pour tout cela il faut que le fait de cliquer sur un bouton, ou de passer au dessus d'un élément, déclenche un événement qui sera intercepté par le composant visé pour qu'il se mette à jour.

Dans cette page nous allons regarder 3 exemples :

  • Hello World avec la possibilité de mettre son nom à la place de World
  • Une application qui interagit avec la frappe au clavier
  • Un tableau de bord assez complet qui fait intervenir de nombreux concepts

Comme pour le cours précédant, je vous invite à regarder aussi le tutorial de Dash sur les fonctions de rappel.

Hello World!¶

Cette exemple permet d'introduire les connexions entre la présentation qui se base sur du HTML comme on l'a vu au cours précédant et des fonction Python. Pour cela

  • les composants HTML ont des identitifiants id
  • un décorateur @app.callback permet
    • de relier dans l'ordre ces identifiants aux arguments d'une fonction (partie Input)
    • de récupérer le résultat et de l'affecter au composant (partie Output)

Ce n'est pas le cas dans cet exemple mais il y est possible d'avoir plusieurs Input. Dans ce cas on les range dans une liste (cf dernier exemple).

In [1]:
%%writefile /tmp/dash2.py

import dash
from dash import dcc
from dash import html
from dash.dependencies import Input,Output

app = dash.Dash(__name__)

app.layout = html.Div(children = [
    html.H1(id="the_output"), 
    dcc.Input(id="the_input")
])

@app.callback(
    Output(component_id='the_output', component_property='children'),  # Output before Input
    Input(component_id='the_input', component_property='value')
)
def cb(input_value):
    return f"Hello {input_value or 'World'}!"

if __name__ == '__main__':
    app.run_server(host='0.0.0.0', debug=True, port=8052)
Writing /tmp/dash2.py

Si vous lancez cette application sur le serveur web de l'EPITA alors vous allez entrer en collision avec l'application lancée par les autres. Aussi il est préférable de recopier le code sur sa machine et de le lancer localement (si on fait tourner cette feuille Jupyter sur sa machine, alors tout va bien, on peut lancer la cellule suivante sans risque).

Si vous désirez relancer votre application, il faut tuer celle qui tourne avec :

!ps x | grep dash2 | awk '{print $1}' | xargs kill -9 
In [2]:
import os
get_ipython().system = os.system  # needed to run command in background

!python3 /tmp/dash2.py &
Out[2]:
0

Attention, '0.0.0.0' est pour Docker, le véritable lien est http://127.0.0.1:8052/ ou http://python3.mooc.lrde.epita.fr:8052/.

Interaction clavier¶

Cet exemple fait intervenir un troisième état pour la fonction de rappel, à savoir State, qui permet d'obtenir l'état et la valeur d'un composant HTML.

Essayez de deviner de deviner ce qui se passe si j'entre **This** is *nice* dans le dcc.Input (offrez-vous un verre si vous trouvez exactement la réponse !).

In [3]:
%%writefile /tmp/dash3.py
import dash
from dash import dcc
from dash import html
from dash.dependencies import Input, Output, State

app = dash.Dash()

app.layout = html.Div([
    dcc.Input(id='my-free-form', value='', type='text'),
    html.Div(dcc.Markdown(id='my-text-displayed'))
])


@app.callback(
    Output(component_id='my-text-displayed', component_property='children'),
    Input(component_id='my-free-form', component_property='value'),
    State(component_id='my-text-displayed', component_property='children')
)
def update_output_div(input_value, current_text): # values come from Input & State
    if current_text == None:
        return "Please type something"
    if len(input_value) == 0:
        current_text = ''
    return "%s  \n%s" % (current_text, input_value)

if __name__ == '__main__':
    app.run_server(host="0.0.0.0", debug=True, port=8053)
Writing /tmp/dash3.py
In [4]:
get_ipython().system = os.system  # needed to run command in background
!python3 /tmp/dash3.py &
Out[4]:
0

host='0.0.0.0' est pour Docker, le véritable lien est http://127.0.0.1:8053/ ou http://python3.mooc.lrde.epita.fr:8053/.

C'est tout !¶

À partir de là vous avez tous les éléments pour faire un tableau de bord. Tout le reste n'est que détail à savoir

  • connaître les composants possibles, cf Dash HTML et Dash Core avec une description des composants et des exemples d'usage
  • lire la documentation des composants qui a des informations plus précise parfois (en tapant html.Button? par exemple)
  • trouver les component_property à utiliser pour les callback (cachés dans les exemples ou dans le message d'erreur lorsqu'on met n'importe quoi)
  • comprendre le style qui dérive du style de HTML, voir ce tutorial de CSS par exemple. Il y a néanmoins une différence importante : les mots clefs utilsent la notation dite camelCased et donc 'text-align' en CSS devient 'textAlign' dans la partie style de Dash

Attention aux variables globales¶

Le dernier gros détail concerne les variables globales. Dans l'exemple complet qu'on donne pour finir on utilise la variable globale df pour le DataFrame qui stocke nos données. Mais si

  • le tableau de bord peut modifier cette variable (ce qui n'est pas le cas dans l'exemple suivant)
  • plusieurs personnes sont connectées en même temps au tableau de bord

alors une interaction effectuée par un utilisateur peut modifier le tableau de bord de tous les utilisateurs ce qui n'est généralement pas le comportement voulu. Pour éviter cela regardez le tutorial sur le partage de données entre fonctions de rappel.

Un exemple plus complet¶

Un tableau de bord pour voir l'évolution nombre d'enfants / revenu avec des courbes temporelles pour chaque pays.

Les données proviennent de la banque mondiale : https://datacatalog.worldbank.org/search/dataset/0037712. Seules certaines données ont été conservées et mises en forme pour permettre l'affichage de px.scatter.

N'hésitez pas à prendre de temps de comprendre le code et de modifier la mise en page.

In [5]:
import pandas as pd

df = pd.read_pickle('data/subWDIdata.pkl')
df.loc[2019]
Out[5]:
Country Name Country Code region fertility incomes population
2019 Afghanistan AFG Asia 4.321 490.232314 38041757.0
2019 Albania ALB Europe 1.597 4319.487639 2854191.0
2019 Algeria DZA Africa 2.988 3114.080730 43053054.0
2019 Angola AGO Africa 5.442 1700.318461 31825299.0
2019 Argentina ARG Americas 2.247 8160.572029 44938712.0
... ... ... ... ... ... ...
2019 Vanuatu VUT Oceania 3.744 2862.238509 299882.0
2019 Vietnam VNM Asia 2.050 2162.500744 96462108.0
2019 Yemen, Rep. YEM Asia 3.700 745.893155 29161922.0
2019 Zambia ZMB Africa 4.559 1049.041583 17861034.0
2019 Zimbabwe ZWE Africa 3.531 1208.279870 14645473.0

168 rows × 6 columns

In [6]:
%%writefile /tmp/population.py
import sys
import dash
import flask
from dash import dcc
from dash import html
import pandas as pd
import numpy as np
import plotly.graph_objects as go
import plotly.express as px

class WorldStats():
    START = 'Start'
    STOP  = 'Stop'

    def __init__(self):
        self.df = pd.read_pickle('data/subWDIdata.pkl')
        self.continent_colors = {'Asia':'gold', 'Europe':'green', 'Africa':'brown', 'Oceania':'red', 
                                 'Americas':'navy'}
        self.years = sorted(set(self.df.index.values))

        self.app = dash.Dash()
        self.app.layout = html.Div(children=[
            html.H3(children='World Stats'),

            html.Div('Move the mouse over a bubble to get information about the country'), 

            html.Div([
                    html.Div([ dcc.Graph(id='main-graph'), ], style={'width':'90%', }),

                    html.Div([
                        html.Div('Continents:'),
                        dcc.Checklist(
                            id='crossfilter-which-continent',
                            options=[{'label': i, 'value': i} for i in sorted(self.continent_colors.keys())],
                            value=sorted(self.continent_colors.keys()),
                            labelStyle={'display':'block'},
                        ),
                        html.P(),
                        html.Div('X scale'),
                        dcc.RadioItems(
                            id='crossfilter-xaxis-type',
                            options=[{'label': i, 'value': i} for i in ['Linear', 'Log']],
                            value='Log',
                            labelStyle={'display':'block'},
                        )
                    ], style={'margin-left':'15px', 'width': '8%', 'float':'right'}),
                ], style={
                    'padding': '10px 50px', 
                    'display':'flex',
                    'justifyContent':'center'
                }),            
            
            html.Div([
                html.Div(
                    dcc.Slider(
                            id='crossfilter-year-slider',
                            min=self.years[0],
                            max=self.years[-1],
                            step = 1,
                            value=self.years[0],
                            marks={str(year): str(year) for year in self.years[::5]},
                    ),
                    style={'display':'inline-block', 'width':"90%"}
                ),
                dcc.Interval(            # fire a callback periodically
                    id='auto-stepper',
                    interval=500,       # in milliseconds
                    max_intervals = -1,  # start running
                    n_intervals = 0
                ),
                html.Button(
                    self.START,
                    id='button-start-stop', 
                    style={'display':'inline-block'}
                ),
                ], style={
                    'padding': '0px 50px', 
                    'width':'100%'
                }),

            html.P(),
            html.Div(id='div-country'),

            html.Div([
                dcc.Graph(id='income-time-series', 
                          style={'width':'33%', 'display':'inline-block'}),
                dcc.Graph(id='fertility-time-series',
                          style={'width':'33%', 'display':'inline-block', 'padding-left': '0.5%'}),
                dcc.Graph(id='pop-time-series',
                          style={'width':'33%', 'display':'inline-block', 'padding-left': '0.5%'}),
            ], style={ 'display':'flex', 'justifyContent':'center', }),

        ], style={
                'borderBottom': 'thin lightgrey solid',
                'backgroundColor': 'rgb(240, 240, 240)',
                 'padding': '10px 50px 10px 50px',
                 }
        )
        
        # I link callbacks here since @app decorator does not work inside a class
        # (somhow it is more clear to have here all interaction between functions and components)
        self.app.callback(
            dash.dependencies.Output('main-graph', 'figure'),
            [ dash.dependencies.Input('crossfilter-which-continent', 'value'),
              dash.dependencies.Input('crossfilter-xaxis-type', 'value'),
              dash.dependencies.Input('crossfilter-year-slider', 'value')])(self.update_graph)
        self.app.callback(
            dash.dependencies.Output('div-country', 'children'),
            dash.dependencies.Input('main-graph', 'hoverData'))(self.country_chosen)
        self.app.callback(
            dash.dependencies.Output('button-start-stop', 'children'),
            dash.dependencies.Input('button-start-stop', 'n_clicks'),
            dash.dependencies.State('button-start-stop', 'children'))(self.button_on_click)
        # this one is triggered by the previous one because we cannot have 2 outputs for the same callback
        self.app.callback(
            dash.dependencies.Output('auto-stepper', 'max_interval'),
            [dash.dependencies.Input('button-start-stop', 'children')])(self.run_movie)
        # triggered by previous
        self.app.callback(
            dash.dependencies.Output('crossfilter-year-slider', 'value'),
            dash.dependencies.Input('auto-stepper', 'n_intervals'),
            [dash.dependencies.State('crossfilter-year-slider', 'value'),
             dash.dependencies.State('button-start-stop', 'children')])(self.on_interval)
        self.app.callback(
            dash.dependencies.Output('income-time-series', 'figure'),
            [dash.dependencies.Input('main-graph', 'hoverData'),
             dash.dependencies.Input('crossfilter-xaxis-type', 'value')])(self.update_income_timeseries)
        self.app.callback(
            dash.dependencies.Output('fertility-time-series', 'figure'),
            [dash.dependencies.Input('main-graph', 'hoverData'),
             dash.dependencies.Input('crossfilter-xaxis-type', 'value')])(self.update_fertility_timeseries)
        self.app.callback(
            dash.dependencies.Output('pop-time-series', 'figure'),
            [dash.dependencies.Input('main-graph', 'hoverData'),
             dash.dependencies.Input('crossfilter-xaxis-type', 'value')])(self.update_pop_timeseries)


    def update_graph(self, regions, xaxis_type, year):
        dfg = self.df.loc[year]
        dfg = dfg[dfg['region'].isin(regions)]
        fig = px.scatter(dfg, x = "incomes", y = "fertility", 
                          size = "population", size_max=60, 
                          color = "region", color_discrete_map = self.continent_colors,
                          hover_name="Country Name", log_x=True)
        fig.update_layout(
                 title = f"{year}",
                 xaxis = dict(title='Adjusted net national income per capita (2020 US$)',
                              type= 'linear' if xaxis_type == 'Linear' else 'log',
                              range=(0,100000) if xaxis_type == 'Linear' 
                                              else (np.log10(50), np.log10(100000)) 
                             ),
                 yaxis = dict(title='Child per woman', range=(0,9)),
                 margin={'l': 40, 'b': 30, 't': 10, 'r': 0},
                 height=450,
                 hovermode='closest',
                 showlegend=False,
             )
        return fig

    def create_time_series(self, country, what, axis_type, title):
        return {
            'data': [go.Scatter(
                x = self.years,
                y = self.df[self.df["Country Name"] == country][what],
                mode = 'lines+markers',
            )],
            'layout': {
                'height': 225,
                'margin': {'l': 50, 'b': 20, 'r': 10, 't': 20},
                'yaxis': {'title':title,
                          'type': 'linear' if axis_type == 'Linear' else 'log'},
                'xaxis': {'showgrid': False}
            }
        }


    def get_country(self, hoverData):
        if hoverData == None:  # init value
            return self.df['Country Name'].iloc[np.random.randint(len(self.df))]
        return hoverData['points'][0]['hovertext']

    def country_chosen(self, hoverData):
        return self.get_country(hoverData)

    # graph incomes vs years
    def update_income_timeseries(self, hoverData, xaxis_type):
        country = self.get_country(hoverData)
        return self.create_time_series(country, 'incomes', xaxis_type, 'GDP per Capita (US $)')

    # graph children vs years
    def update_fertility_timeseries(self, hoverData, xaxis_type):
        country = self.get_country(hoverData)
        return self.create_time_series(country, 'fertility', xaxis_type, 'Child per woman')

    # graph population vs years
    def update_pop_timeseries(self, hoverData, xaxis_type):
        country = self.get_country(hoverData)
        return self.create_time_series(country, 'population', xaxis_type, 'Population')

    # start and stop the movie
    def button_on_click(self, n_clicks, text):
        if text == self.START:
            return self.STOP
        else:
            return self.START

    # this one is triggered by the previous one because we cannot have 2 outputs
    # in the same callback
    def run_movie(self, text):
        if text == self.START:    # then it means we are stopped
            return 0 
        else:
            return -1

    # see if it should move the slider for simulating a movie
    def on_interval(self, n_intervals, year, text):
        if text == self.STOP:  # then we are running
            if year == self.years[-1]:
                return self.years[0]
            else:
                return year + 1
        else:
            return year  # nothing changes

    def run(self, debug=False, port=8050):
        self.app.run_server(host="0.0.0.0", debug=debug, port=port)


if __name__ == '__main__':
    ws = WorldStats()
    ws.run(port=8055)
Writing /tmp/population.py
In [7]:
get_ipython().system = os.system  # needed to run command in background
!python3 /tmp/population.py &
Out[7]:
0

Le bon lien est http://127.0.0.1:8055/ ou http://python3.mooc.lrde.epita.fr:8055/ mais il est préférable de regarder sur https://delta.lrde.epita.fr/population où ce code tourne avec le serveur spécialisté gunicorn.

In [8]:
!ps x |grep population | awk '{print $1}' | xargs kill -9 
kill: (4480): No such process
Out[8]:
9
In [ ]: