from dash import Dash, dcc, html, Input, Output
import dash_ag_grid as dag
import dash_bootstrap_components as dbc
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
import webbrowser
BOOTSTRAP_LIGHT = "https://cdn.jsdelivr.net/npm/bootswatch@5.3.2/dist/flatly/bootstrap.min.css"
BOOTSTRAP_DARK = "https://cdn.jsdelivr.net/npm/bootswatch@5.3.2/dist/darkly/bootstrap.min.css"
app = Dash(__name__, external_stylesheets=[BOOTSTRAP_LIGHT])
df = pd.read_csv(
'https://raw.githubusercontent.com/plotly/Figure-Friday/refs/heads/main/2025/week-17/un-migration-2024.csv'
)
year = '2024'
df[year] = pd.to_numeric(df[year], errors='coerce')
EXCLUDE_KEYWORDS = [
"other", "stateless", "region", "subregion", "income", "developed",
"unspecified", "group", "area", "total", "not elsewhere", "refugees",
"asylum", "africa", "europe", "asia", "oceania", "america", "caribbean",
"melanesia", "micronesia", "polynesia", "northern", "southern", "western",
"eastern", "central", "oceania"
]
def is_real_country(name):
if not isinstance(name, str):
return False
lname = name.lower()
return all(
(kw not in lname if kw not in ["africa", "europe", "asia", "oceania"] else kw != lname)
for kw in EXCLUDE_KEYWORDS
)
all_countries = sorted(
set(
n for n in pd.concat([df['Origin'], df['Destination']])
if is_real_country(n)
)
)
app.layout = html.Div([
dcc.Store(id='theme-store', data='light'),
html.Div(
dbc.Row([
dbc.Col(
html.Label("Dark/Light mode", style={"fontSize": "16px", "fontWeight": "bold"}),
width="auto"
),
dbc.Col(
dbc.Switch(
id='theme-switch',
label="🌗",
value=False,
style={"display": "block", "margin": "auto", "textAlign": "center"}
),
width="auto", style={"textAlign": "center"}
)
], justify="center", align="center", style={"marginBottom": "40px"})
),
html.Div([
html.Div(
html.H2("How do people migrate between countries in 2024?"),
id="title-box"
),
html.Div([
html.Div([
html.Label("Select country:"),
dcc.Dropdown(
id='entity-dropdown',
options=[{'label': c, 'value': c} for c in all_countries],
value='Peru' if 'Peru' in all_countries else all_countries[0],
clearable=False,
style={'width': '250px'}
),
]),
html.Div([
html.Label("Flow direction:"),
dcc.RadioItems(
id='direction-radio',
options=[
{'label': 'Immigration (To)', 'value': 'to'},
{'label': 'Emigration (From)', 'value': 'from'}
],
value='to',
inline=True
),
]),
html.Div([
html.Label("Map projection:"),
dcc.Dropdown(
id='projection-dropdown',
options=[
{'label': 'Robinson', 'value': 'robinson'},
{'label': 'Mercator', 'value': 'mercator'},
{'label': 'Azimuthal Equal Area', 'value': 'azimuthal equal area'},
{'label': 'Orthographic', 'value': 'orthographic'},
],
value='robinson',
clearable=False,
style={'width': '250px'}
),
]),
html.Div([
html.Label("Color scale:"),
dcc.Dropdown(
id='colorscale-dropdown',
options=[
{'label': 'Speed', 'value': 'speed'},
{'label': 'Viridis', 'value': 'viridis'},
{'label': 'Plasma', 'value': 'plasma'},
{'label': 'Blues', 'value': 'blues'},
],
value='speed',
clearable=False,
style={'width': '150px'}
),
]),
], id='controls-row', style={
"display": "flex",
"gap": "40px",
"alignItems": "center",
"flexWrap": "wrap",
"justifyContent": "center"
}),
html.Br(),
html.Div(id="kpi-box"),
html.Br(),
html.Div("ℹ️ Click a country on the map to open its Wikipedia page in a new tab.",
style={"fontSize": "18px", "textAlign": "center", "fontStyle": "italic"}),
html.Div([
dcc.Graph(
id='choropleth-map',
style={'height': '800px', 'cursor': 'pointer'} # <-- pointer cursor here
),
]),
html.Hr(),
html.H4("Detailed migration flows for your selection in 2024"),
html.Div(
dag.AgGrid(
id='data-table',
columnDefs=[{"field": i, 'filter': True, 'sortable': True} for i in df.columns],
dashGridOptions={"pagination": True},
columnSize="sizeToFit",
style={'height': '400px'}
)
)
], id="main-box", style={"padding": "40px"})
])
@app.callback(
Output('kpi-box', 'children'),
Output('choropleth-map', 'figure'),
Output('data-table', 'rowData'),
Input('entity-dropdown', 'value'),
Input('direction-radio', 'value'),
Input('projection-dropdown', 'value'),
Input('colorscale-dropdown', 'value'),
Input('theme-store', 'data')
)
def update_map(selected_entity, direction, projection, colorscale, theme):
y = '2024'
if direction == 'to':
flows = df[df['Destination'] == selected_entity]
flows = flows[flows[y] > 0]
map_title = (
f"Where migrants <span style='color:green; font-size: 28px;'>to</span> "
f"<span style='color:green; font-size: 28px;'>{selected_entity}</span> come from"
)
map_group = 'Origin'
kpi_label = "Top immigration source"
else:
flows = df[df['Origin'] == selected_entity]
flows = flows[flows[y] > 0]
map_title = (
f"Where migrants <span style='color:red; font-size: 28px;'>from</span> "
f"<span style='color:red; font-size: 28px;'>{selected_entity}</span> go"
)
map_group = 'Destination'
kpi_label = "Top emigration destination"
flows = flows[flows['Origin'].apply(is_real_country) & flows['Destination'].apply(is_real_country)].drop_duplicates(subset=['Origin', 'Destination', '2024'])
if not flows.empty:
top_row = flows.loc[flows[y].idxmax()]
top_country = top_row[map_group]
top_value = int(top_row[y])
kpi_text = f"{kpi_label}: {top_country} ({top_value:,} people)"
else:
kpi_text = "No data for this selection."
if not flows.empty:
vals = flows[y].dropna()
vmin = np.percentile(vals, 5)
vmax = np.percentile(vals, 95)
if vmin == vmax:
vmin = vals.min()
vmax = vals.max()
map_fig = px.choropleth(
flows,
locations=map_group,
locationmode='country names',
color=y,
hover_name=map_group,
title=map_title,
color_continuous_scale=colorscale or 'turbo',
range_color=[vmin, vmax],
height=800
)
map_fig.update_traces(
hovertemplate="<b>%{location}</b><br>Migrants: %{z:,.0f}<extra></extra>",
customdata=flows[map_group]
)
map_fig.update_geos(
projection_type=projection,
showcountries=True,
showcoastlines=True,
showland=True,
landcolor="lightgrey" if theme == 'light' else "#333",
oceancolor="#cce6ff" if theme == 'light' else "#111",
showocean=True
)
map_fig.update_layout(
template="plotly_white" if theme == 'light' else "plotly_dark",
coloraxis_colorbar=dict(title='Migrants'),
clickmode='event+select',
margin=dict(t=60, l=10, r=10, b=10)
)
else:
map_fig = go.Figure()
map_fig.update_layout(title_text="No data for map.", height=800)
return kpi_text, map_fig, flows.to_dict("records")
@app.callback(
Output('choropleth-map', 'clickData'),
Input('choropleth-map', 'clickData'),
prevent_initial_call=True
)
def open_wikipedia(clickData):
if clickData and 'points' in clickData:
country = clickData['points'][0]['location']
if country:
webbrowser.open_new_tab(f"https://en.wikipedia.org/wiki/{country.replace(' ', '_')}")
return None
@app.callback(
Output('theme-store', 'data'),
Input('theme-switch', 'value')
)
def update_theme_store(is_dark):
return 'dark' if is_dark else 'light'
if __name__ == "__main__":
app.run(debug=True)