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
)
- de relier dans l'ordre ces identifiants aux arguments d'une fonction (partie
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).
%%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
import os
get_ipython().system = os.system # needed to run command in background
!python3 /tmp/dash2.py &
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 !).
%%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
get_ipython().system = os.system # needed to run command in background
!python3 /tmp/dash3.py &
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 lescallback
(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 partiestyle
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.
import pandas as pd
df = pd.read_pickle('data/subWDIdata.pkl')
df.loc[2019]
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
%%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
get_ipython().system = os.system # needed to run command in background
!python3 /tmp/population.py &
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.
!ps x |grep population | awk '{print $1}' | xargs kill -9
kill: (4480): No such process
9