import dash
from dash import dcc, html, Input, Output, State, callback_context
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
from datetime import datetime
import json
# For a real scenario, we would load the dataset like this:
df = pd.read_csv('un_migration_2024_cleaned')
# Initialize the Dash app with Bootstrap UNITED theme and Font Awesome
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.UNITED,'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css'])
app.title="UN Migration Dashboard 2024"
server = app.server
# Styles updated with UNITED theme
BACKGROUND_COLOR = "#f2efe8" # Light beige background for subtle contrast
CARD_COLOR = "#ffffff"
PRIMARY_COLOR = "#E95420" # UNITED Orange
SECONDARY_COLOR = "#772953" # UNITED Purple
ACCENT_COLOR = "#AEA79F" # UNITED Gray accent
TEXT_COLOR = "#333333"
# Chart colors
CHART_COLORS = [
'#E95420', # Primary orange
'#772953', # Secondary purple
'#AEA79F', # Accent gray
'#38B44A', # Green
'#17A2B8', # Light blue
'#EFB73E', # Yellow
'#DF382C', # Red
'#772953', # Dark purple
'#6E8898', # Gray blue
'#868e96' # Medium gray
]
# function to format numbers for display
def format_number(num):
"""Format numbers with k/M/B suffixes for better readability"""
if num >= 1_000_000_000:
return f"{num/1_000_000_000:.1f}B"
elif num >= 1_000_000:
return f"{num/1_000_000:.1f}M"
elif num >= 1_000:
return f"{num/1_000:.1f}k"
else:
return f"{num:,.0f}"
# Function to create KPI cards with emoji-style icons
def create_kpi_card(title, value, subtitle=None, icon=None, color=PRIMARY_COLOR):
card = dbc.Card(
dbc.CardBody([
html.Div([
# Icon section
html.Div([
html.Span(icon,
style={"fontSize": "2rem", "color": color, "marginRight": "15px"}) if icon else None,
], className="d-flex align-items-center"),
# Content section
html.Div([
html.H5(title, className="text-muted mb-1", style={"fontSize": "0.9rem"}),
html.H3(value if isinstance(value, str) else f"{value:,}",
className="mb-0", style={"fontWeight": "bold", "color": color}),
html.P(subtitle, className="text-muted mt-2 mb-0", style={"fontSize": "0.8rem"}) if subtitle else None,
], className="flex-grow-1"),
], className="d-flex align-items-start")
]),
className="shadow-sm h-100",
style={"borderTop": f"3px solid {color}", "borderRadius": "0.5rem", "backgroundColor": CARD_COLOR}
)
return card
# Dashboard layout
app.layout = dbc.Container([
dbc.Row([
dbc.Col([
html.Div([
html.H1("π Global Migration Patterns 2024",
className="my-4",
style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
html.P([
"Exploring origins of migrants towards ",
html.Span(id="selected-country-subtitle",
style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
], className="lead", style={"color": SECONDARY_COLOR}),
dbc.Button("Select Migrants Destination",
id="open-modal-btn",
color="primary",
className="mt-3 mb-4")
])
], width=12)
], className="mb-2")
,
# KPI row
dbc.Row([
dbc.Col([
html.Div(id="total-migrants-card")
], width=3),
dbc.Col([
html.Div(id="top-origin-card")
], width=3),
dbc.Col([
html.Div(id="avg-migration-card")
], width=3),
dbc.Col([
html.Div(id="routes-card")
], width=3)
], className="mb-4"),
# Main charts row
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader([
html.Span("Migration Flows to ",
className="font-weight-bold",
style={"fontSize": "1.1rem"}),
html.Span(id="sankey-title-country",
className="font-weight-bold",
style={"color": PRIMARY_COLOR, "fontSize": "1.1rem"})
], style={"backgroundColor": "#f8f9fa"}),
dbc.CardBody([
dcc.Graph(id="sankey-graph", style={'height': '500px'})
], style={"backgroundColor": CARD_COLOR})
], className="shadow-sm h-100", style={"borderRadius": "0.5rem"})
], width=7),
dbc.Col([
dbc.Card([
dbc.CardHeader("Continental Origins",
className="font-weight-bold",
style={"backgroundColor": "#f8f9fa", "fontSize": "1.1rem"}),
dbc.CardBody([
dcc.Graph(id="continent-distribution", style={'height': '500px'})
], style={"backgroundColor": CARD_COLOR})
], className="shadow-sm", style={"borderRadius": "0.5rem"})
], width=5)
], className="mb-4"),
# Second row of charts
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader("Top Migration Routes",
className="font-weight-bold",
style={"backgroundColor": "#f8f9fa", "fontSize": "1.1rem"}),
dbc.CardBody([
dcc.Graph(id="routes-ranking", style={'height': '400px'})
], style={"backgroundColor": CARD_COLOR})
], className="shadow-sm h-100", style={"borderRadius": "0.5rem"})
], width=12)
]),
# Country selection modal
dbc.Modal([
dbc.ModalHeader("Select Migrants Destination Country", style={"backgroundColor": PRIMARY_COLOR, "color": "white"}),
dbc.ModalBody([
dbc.Input(
id="search-country",
placeholder="Search for a country...",
type="text",
className="mb-3"
),
html.Div([
dbc.Tabs([
dbc.Tab(label="All Regions", tab_id="all",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="Africa", tab_id="Africa",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="Asia", tab_id="Asia",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="Europe", tab_id="Europe",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="North America", tab_id="North America",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="South America", tab_id="South America",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
dbc.Tab(label="Oceania", tab_id="Oceania",
label_style={"color": SECONDARY_COLOR},
active_label_style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
], id="continent-tabs", active_tab="all"),
html.Div(id="countries-grid", className="mt-3", style={"maxHeight": "400px", "overflowY": "auto"})
])
]),
dbc.ModalFooter(
dbc.Button("Close", id="close-modal-btn", color="secondary", className="ml-auto")
)
], id="country-modal", size="lg"),
# Store the selected country
dcc.Store(id="selected-country", data="United States"),
# Footer
html.Div([
html.Hr(style={"width": "100%", "margin":"auto"}),
html.P("Dashboard Created with Dash-Python | Data Sourced: Thank you to the UN Population Division",
style={'textAlign': 'center', 'color': '#772953', 'margin': '0'}),
html.Hr(style={"width": "100%", "margin": "auto", "color":'#772953'})
], style={"marginTop": "20px",}
)
], fluid=True, style={"backgroundColor": BACKGROUND_COLOR, "minHeight": "100vh", "padding": "20px"})
# Callback to open/close modal
@app.callback(
Output("country-modal", "is_open"),
[Input("open-modal-btn", "n_clicks"), Input("close-modal-btn", "n_clicks")],
[State("country-modal", "is_open")],
)
def toggle_modal(n1, n2, is_open):
if n1 or n2:
return not is_open
return is_open
# Callback to fill countries grid based on tab and search
@app.callback(
Output("countries-grid", "children"),
[Input("continent-tabs", "active_tab"), Input("search-country", "value")]
)
def update_countries_grid(active_tab, search_value):
# Get unique destination countries with stats
dest_stats = df.groupby('Destination_country')['2024'].agg(['sum', 'count']).reset_index()
dest_stats = dest_stats.sort_values('sum', ascending=False)
unique_destinations = dest_stats['Destination_country'].tolist()
# Create a dictionary mapping country to its continent (do this once)
country_continent_map = df.set_index('Destination_country')['Destination_continent'].to_dict()
# Filter by continent if needed
if active_tab != "all":
filtered_countries = [
country for country in unique_destinations
if country_continent_map.get(country) == active_tab
]
else:
filtered_countries = unique_destinations
# Filter by search text if present
if search_value:
filtered_countries = [
country for country in filtered_countries
if search_value.lower() in country.lower()
]
# Create grid of country cards
country_cards = []
for country in filtered_countries:
# Get data for the card
country_data = dest_stats[dest_stats['Destination_country'] == country]
if not country_data.empty:
total_migrants = country_data['sum'].values[0]
num_origins = country_data['count'].values[0]
# Determine main origin for this destination (with error handling)
try:
top_origin = df[df['Destination_country'] == country].groupby('Origin_country')['2024'].sum().idxmax()
except:
top_origin = "N/A"
# Create country card with statistics and improved style
country_card = dbc.Card([
dbc.CardBody([
html.H5(country, className="card-title", style={"color": SECONDARY_COLOR, "fontWeight": "bold"}),
html.P([
html.Span(f"{format_number(total_migrants)}", style={"color": PRIMARY_COLOR, "fontWeight": "bold"}),
" migrants"
], className="card-text mb-1"),
html.P(f"From {num_origins} countries", className="text-muted small mb-1"),
html.P(f"Main source: {top_origin}", className="text-muted small mb-2"),
dbc.Button("Select",
id={"type": "country-select-btn", "index": country},
size="sm",
color="primary",
className="w-100")
])
], className="h-100 shadow-sm", style={"borderRadius": "0.5rem", "backgroundColor": CARD_COLOR})
country_cards.append(dbc.Col(country_card, width=4, className="mb-3"))
# Organize in rows
rows = []
for i in range(0, len(country_cards), 3):
rows.append(dbc.Row(country_cards[i:i+3], className="mb-3"))
return html.Div(rows)
# Callback to select country and close modal
@app.callback(
[Output("selected-country", "data"),
Output("country-modal", "is_open", allow_duplicate=True)],
[Input({"type": "country-select-btn", "index": dash.ALL}, "n_clicks")],
[State({"type": "country-select-btn", "index": dash.ALL}, "id")],
prevent_initial_call=True
)
def select_country(btn_clicks, btn_ids):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
# Get which button was pressed
button_id = ctx.triggered[0]['prop_id'].split('.')[0]
button_data = json.loads(button_id)
selected_country = button_data['index']
return selected_country, False
# 2. Callback to update the subtitule with selected country
@app.callback(
Output("selected-country-subtitle", "children"),
[Input("selected-country", "data")]
)
def update_subtitle(selected_country):
if not selected_country:
return "selected destination countries"
return selected_country
# Callback to update all visualizations
@app.callback(
[Output("sankey-graph", "figure"),
Output("continent-distribution", "figure"),
Output("routes-ranking", "figure"),
Output("total-migrants-card", "children"),
Output("top-origin-card", "children"),
Output("avg-migration-card", "children"),
Output("routes-card", "children"),
Output("sankey-title-country", "children")],
[Input("selected-country", "data")]
)
def update_dashboard(selected_country):
if not selected_country:
raise PreventUpdate
# Filter data for selected country
df_filtered = df[df['Destination_country'] == selected_country]
if df_filtered.empty:
# Create empty visualizations or with "no data" messages
no_data_msg = "No data available for this country"
# Empty Sankey
sankey_fig = go.Figure()
sankey_fig.update_layout(
annotations=[dict(
text="No migration data available for this destination",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=500,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# Empty Treemap
treemap_fig = go.Figure()
treemap_fig.update_layout(
annotations=[dict(
text="No continental distribution data available",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=500,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# Empty Routes ranking
routes_fig = go.Figure()
routes_fig.update_layout(
annotations=[dict(
text="No migration routes data available",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=400,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# KPI cards
total_card = create_kpi_card("TOTAL MIGRANTS", 0, f"Destination: {selected_country}", color=PRIMARY_COLOR)
top_origin_card = create_kpi_card("TOP ORIGIN", "N/A", "No data available", color="#38B44A")
avg_card = create_kpi_card("AVERAGE PER ORIGIN", 0, "No data available", color="#17A2B8")
routes_card = create_kpi_card("CONCENTRATION", "0%", "No data available", color="#EFB73E")
return (sankey_fig, treemap_fig, routes_fig,
total_card, top_origin_card, avg_card, routes_card,
selected_country)
# Check if there's data (values greater than zero)
if df_filtered['2024'].sum() == 0:
# Create empty visualizations with "zero data" message
no_data_msg = "Migration data for this country are all zero"
# Empty Sankey
sankey_fig = go.Figure()
sankey_fig.update_layout(
annotations=[dict(
text="No recorded migration flows for this destination",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=500,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# Empty Treemap
treemap_fig = go.Figure()
treemap_fig.update_layout(
annotations=[dict(
text="No continental distribution to show (all values are zero)",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=500,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# Empty Routes ranking
routes_fig = go.Figure()
routes_fig.update_layout(
annotations=[dict(
text="No migration routes with positive values",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)],
height=400,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# KPI cards
total_card = create_kpi_card("TOTAL MIGRANTS", 0, f"Destination: {selected_country}", color=PRIMARY_COLOR)
top_origin_card = create_kpi_card("TOP ORIGIN", "N/A", "All values are zero", color="#38B44A")
avg_card = create_kpi_card("AVERAGE PER ORIGIN", 0, f"From {len(df_filtered)} countries", color="#17A2B8")
routes_card = create_kpi_card("CONCENTRATION", "0%", "No positive values", color="#EFB73E")
return (sankey_fig, treemap_fig, routes_fig,
total_card, top_origin_card, avg_card, routes_card,
selected_country)
# If there's valid data, continue with normal analysis
# 1. Create Sankey chart with the new color palette
df_sankey = df_filtered.sort_values('2024', ascending=False).head(10) # Top 10 origins
# Prepare data for Sankey
origins = df_sankey['Origin_country'].tolist()
values = df_sankey['2024'].tolist()
# Create nodes and links with UNITED theme colors
labels = origins + [selected_country]
source_indices = list(range(len(origins)))
target_indices = [len(origins)] * len(origins)
# Generate colors for nodes
# Destination node (last) uses primary color
color_nodes = [CHART_COLORS[i % len(CHART_COLORS)] for i in range(len(origins))]
color_nodes.append(PRIMARY_COLOR) # Destination always with primary color
# Link colors with transparency
color_links = [f"rgba{tuple(int(c.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) + (0.4,)}"
for c in color_nodes[:-1]] # Exclude the last node (destination)
sankey_fig = go.Figure(data=[go.Sankey(
node=dict(
pad=15,
thickness=20,
line=dict(color="black", width=0.5),
label=labels,
color=color_nodes
),
link=dict(
source=source_indices,
target=target_indices,
value=values,
color=color_links
)
)])
# Add storytelling title and improved layout
sankey_fig.update_layout(
title={
'text': f"The Journey: People Moving to {selected_country}",
'y':0.98,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top',
'font': dict(size=14)
},
font=dict(
family="Arial, sans-serif",
size=12,
color=TEXT_COLOR
),
height=500,
margin=dict(l=20, r=20, t=40, b=20),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
continent_data = df_filtered.groupby('Origin_continent')['2024'].sum().reset_index()
total = continent_data['2024'].sum()
if total > 0:
continent_data['percentage'] = (continent_data['2024'] / total * 100).round(1)
else:
continent_data['percentage'] = 0
# Add additional data for a more detailed treemap
country_data = df_filtered.groupby(['Origin_continent', 'Origin_country'])['2024'].sum().reset_index()
# Filter out rows where '2024' sum is zero before creating the treemap
country_data_filtered = country_data[country_data['2024'] > 0]
# Only create treemap if there's valid data
if not country_data_filtered.empty:
# Create treemap with updated color palette
treemap_fig = px.treemap(
country_data_filtered,
path=['Origin_continent', 'Origin_country'],
values='2024',
color='2024',
labels={'2024':'Total Migrants'},
color_continuous_scale=[PRIMARY_COLOR, SECONDARY_COLOR],
title=f"Where They Come From: Origins of Migrants to {selected_country}"
)
treemap_fig.update_traces(
hovertemplate='<b>%{label}</b><br>Migrants: %{value:,.0f}<br>%{percentRoot:.1%} of total<extra></extra>'
)
else:
# Empty treemap with message
treemap_fig = go.Figure()
treemap_fig.update_layout(
annotations=[dict(
text="Not enough data to generate the treemap (all values are zero)",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)]
)
treemap_fig.update_layout(
height=500,
margin=dict(l=20, r=20, t=40, b=20),
font=dict(
family="Arial, sans-serif",
color=TEXT_COLOR
),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)'
)
# 3. Create table/chart of migration routes ranking
routes_data = df_filtered.sort_values('2024', ascending=False).head(15)
# Calculate percentages only if there's positive data
total_routes = routes_data['2024'].sum()
if total_routes > 0:
routes_data['percentage'] = (routes_data['2024'] / total_routes * 100).round(1)
else:
routes_data['percentage'] = 0
routes_fig = go.Figure()
if len(routes_data) > 0 and routes_data['2024'].sum() > 0:
routes_fig.add_trace(go.Bar(
x=routes_data['Origin_country'],
y=routes_data['2024'],
text=routes_data['2024'].apply(lambda x: f"{format_number(x)}"),
textposition='auto',
marker_color=PRIMARY_COLOR,
marker_line_color=SECONDARY_COLOR,
marker_line_width=1,
opacity=0.75,
hovertemplate='<b>%{x}</b><br>Migrants: %{y:,.0f}<extra></extra>'
))
else:
routes_fig.update_layout(
annotations=[dict(
text="No migration routes with positive values to show",
showarrow=False,
xref="paper", yref="paper",
x=0.5, y=0.5,
font=dict(size=16, color=TEXT_COLOR)
)]
)
routes_fig.update_layout(
title={
'text': f"Most Traveled Paths: Top Migration Routes to {selected_country}",
'y':0.95,
'x':0.5,
'xanchor': 'center',
'yanchor': 'top'
},
xaxis_title="Country of Origin",
yaxis_title="Number of Migrants",
height=400,
margin=dict(l=20, r=20, t=60, b=40),
xaxis_tickangle=-45,
font=dict(
family="Arial, sans-serif",
color=TEXT_COLOR
),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
xaxis=dict(
showgrid=True,
gridcolor='rgba(200,200,200,0.2)'
),
yaxis=dict(
visible=False,
showgrid=True,
gridcolor='rgba(200,200,200,0.2)'
)
)
# Calculate metrics for KPIs
total_migrants = df_filtered['2024'].sum()
# Handle cases where there's no migration or all zeros
if total_migrants > 0:
try:
top_origin = df_filtered.groupby('Origin_country')['2024'].sum().idxmax()
top_origin_value = df_filtered.groupby('Origin_country')['2024'].sum().max()
top_origin_pct = (top_origin_value / total_migrants * 100).round(1)
except:
top_origin = "N/A"
top_origin_value = 0
top_origin_pct = 0
avg_migration = int(df_filtered['2024'].mean())
# Calculate concentration (percentage represented by top 5 countries)
top5_sum = df_filtered.sort_values('2024', ascending=False).head(5)['2024'].sum()
concentration = (top5_sum / total_migrants * 100).round(1)
else:
top_origin = "N/A"
top_origin_value = 0
top_origin_pct = 0
avg_migration = 0
top5_sum = 0
concentration = 0
num_routes = len(df_filtered)
# Create KPI cards with UNITED theme colors and emoji icons
total_card = create_kpi_card(
"TOTAL MIGRANTS",
format_number(total_migrants),
f"Destination: {selected_country}",
icon="π¨βπ©βπ§", # Familia
color=PRIMARY_COLOR
)
top_origin_card = create_kpi_card(
"TOP ORIGIN",
top_origin,
f"{format_number(top_origin_value)} migrants ({top_origin_pct}%)",
icon="π©", # Flag emoji
color="#38B44A" # green
)
avg_card = create_kpi_card(
"AVERAGE PER ORIGIN",
format_number(avg_migration),
f"From {num_routes} different countries",
icon="π", # Chart emoji
color="#17A2B8" # light blue
)
routes_card = create_kpi_card(
"CONCENTRATION",
f"{concentration}%",
f"Top 5 countries {format_number(top5_sum)} migrants",
icon="π", # Location pin emoji
color="#EFB73E" # yellow
)
return (sankey_fig, treemap_fig, routes_fig,
total_card, top_origin_card, avg_card, routes_card,
selected_country)