Hello Everyone Good Afternoon from here,
This Figure Friday, week 17, I’m contributing a dashboard for mutual and continuous learning that showcases global migration patterns, specifically the flow of migrants to selected destination countries. It enables the exploration and analysis of where migrants originate when moving to particular destinations globally, offering insights into migration flows, patterns, and key statistics to understand global human mobility.
Key Features
-
Interactive Country Selection: Users can select any destination country via a modal interface, with options to filter by continent or search for specific countries.
-
Key Performance Indicators (KPIs):
- Total migrants to the selected destination
- Top origin country with percentage contribution
- Average migrants per origin country
- Concentration ratio (percentage represented by top 5 countries)
-
Visualizations:
- Sankey Diagram: Shows the flow of migrants from top origin countries to the selected destination
- Continental Distribution: Treemap visualization breaking down migrant origins by continent and country
- Migration Routes Ranking: Bar chart showing the most traveled migration paths to the destination
Here some images:
The code
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
# app = dash.Dash(__name__, external_stylesheets=[dbc.themes.UNITED])
# 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
]
# Improved 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([
# Header with improved style
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 selected destination countries.",
className="lead",
style={"color": SECONDARY_COLOR})
], className="mb-4"#className="border-bottom pb-3"
)
], width=8),
dbc.Col([
html.Div([
html.P(f"Today: {datetime.now().strftime('%d/%m/%Y')}",
className="text-muted small text-right mb-0"),
dbc.Button("Select Migrants Destination",
id="open-modal-btn",
color="primary",
className="mt-2")
], className="d-flex flex-column align-items-end justify-content-center h-100")
], width=4)
], className="mb-4"
),
# 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
# 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="🌎", # World emoji
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)
the app
Any comments/suggestions more than welcome
Regards