Figure Friday 2025 - week 17

join the Figure Friday session on May 2, at noon Eastern Time, to showcase your creation and receive feedback from the community.

What were the main migration routes in 2024?

Explore this and other questions by using Plotly and Dash on the migration dataset.

According to the UN, the international migrant stock is a measure of the number of persons who are considered international migrants at a given point in time. To access the full dataset, go to the UN Population Division, and click the [Destination and origin] link to the right, under the Data section.

Finally, here’s a cool article on the topic, using the same dataset.

Things to consider:

  • what can you improve in the app or sample figure below (treemap charts)?
  • would you like to tell a different data story using a different graph?
  • can you create a different Dash app?

Sample figure:

Code for sample figure:
from dash import Dash, dcc
import dash_ag_grid as dag
import plotly.express as px
import pandas as pd


df = pd.read_csv('https://raw.githubusercontent.com/plotly/Figure-Friday/refs/heads/main/2025/week-17/un-migration-2024.csv')
df_peru = df[(df['Destination'] == 'Peru') | (df['Origin'] == 'Peru')].copy()
df_peru['2024'] = pd.to_numeric(df_peru['2024'], errors='coerce')
df_peru = df_peru.dropna(subset=['2024'])  # Remove rows where conversion failed
df_peru['2024'] = df_peru['2024'].astype(int)

# Filter flows FROM Peru
flows_from = df_peru[df_peru['Origin'] == 'Peru'].copy()
flows_from['Flow Direction'] = 'From Peru'
flows_from['Location'] = flows_from['Destination']

# Filter flows TO Peru
flows_to = df[df['Destination'] == 'Peru'].copy()
flows_to['Flow Direction'] = 'To Peru'
flows_to['Location'] = flows_to['Origin'] # The other country/region

# Combine the two flows
treemap_df = pd.concat([flows_from, flows_to], ignore_index=True)

# Select relevant columns
treemap_df = treemap_df[['Flow Direction', 'Location', '2024']]

# Remove rows where the value is zero or negligible if you wish
treemap_df = treemap_df[treemap_df['2024'] > 0]

fig = px.treemap(
    treemap_df,
    path=[px.Constant("All Flows"), 'Flow Direction', 'Location'],
    values='2024',
    title='Migration Flows To and From Peru (2024)',
    height=600,
    # Colors - https://plotly.com/python/discrete-color/
    color_discrete_sequence=px.colors.qualitative.Pastel,
    # color='2024',
    hover_data={'2024': ':,'} # Format hover value with commas
)

# Customize layout/appearance
fig.update_layout(
    margin=dict(t=50, l=25, r=25, b=25),
    font_size=12
)

# Customize hover template
fig.update_traces(
    hovertemplate='<b>%{label}</b><br>Direction: %{parent}<br>Migrants (2024): %{value:,}<extra></extra>'
)

grid = dag.AgGrid(
    rowData=df.to_dict("records"),
    columnDefs=[{"field": i, 'filter': True, 'sortable': True} for i in df.columns],
    dashGridOptions={"pagination": True},
    columnSize="sizeToFit"
)

app = Dash()
app.layout = [
    grid,
    dcc.Graph(figure=fig)
]


if __name__ == "__main__":
    app.run(debug=True)

Participation Instructions:

  • Create - use the weekly data set to build your own Plotly visualization or Dash app. Or, enhance the sample figure provided in this post, using Plotly or Dash.
  • Submit - post your creation to LinkedIn or Twitter with the hashtags #FigureFriday and #plotly by midnight Thursday, your time zone. Please also submit your visualization as a new post in this thread.
  • Celebrate - join the Figure Friday sessions to showcase your creation and receive feedback from the community.

:point_right: If you prefer to collaborate with others on Discord, join the Plotly Discord channel.

Data Source:

Thank you to the UN Population Division for the data.

1 Like

I try to make a world map for this dataset. It is sill in progress.

5 Likes

Smart choice going for the choropleth, @Ester . I am surprised to see that China has the second largest amount of Peruvian immigration.

2 Likes

@adamschroeder I might prefer to give the color scale individually, so it doesn’t show much. :thinking:

2 Likes

what do you mean?

1 Like

I’m not sure if I should change the color scale?

1 Like

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

  1. Interactive Country Selection: Users can select any destination country via a modal interface, with options to filter by continent or search for specific countries.

  2. 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)
  3. 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

8 Likes

Hi @Avacsiglo21, I originally wanted a Sankey Diagramm, but it didn’t turn out as nice as yours, so I did something different.:slightly_smiling_face:

2 Likes

Hi Ester Thanks you.
Actually, I had originally created an orthographic map like the others where, upon clicking on a country, a Sankey chart would update, displaying the 10 largest migrations to that country. But I gave up in the end.

2 Likes

He Avacsiglo21, lovely as always. I was wondering, what is the difference between the information on the sankey chart and the barchart?

3 Likes

Great work @Avacsiglo21 , love a nice Sankey chart!

3 Likes

Awesome Dash app, @Avacsiglo21 . I liked how you used the concentration card to highlight the percentage of total immigration, represented by the top 5 countries.

Did you use a special platform or tool to choose the color scheme?

2 Likes

One illustrates the routes (Sankey Chart) and the other shows the difference in the number of migrants (Bar Chart). Even though they have the same information.

2 Likes

Thank you Thomas

2 Likes

Thanks Adam. Just
Implemented Bootstrap’s UNITED theme which has a distinctive color palette with orange (#E95420) as the primary color and purple (#772953) as the secondary color, combine with a warm light beige background allows the dashboard’s main content to take center stage, better than use gray or white.

3 Likes

I was confused by the wording in the title. At least in Europe we have this definition of migration, related to routes:

migration route

Definition(s)

migration route

The geographic route along which migrants and refugees move via hubs in transit areas from their country of origin to their country of destination, often travelling in mixed migration flows.

migration route - European Commission.

1 Like

And now I have 1.5 day left to find out why the colouring of the dots on the maps is off and why the selected country is not mapped on the correct lat/long.

The buggy version regarding the colouring of the dots, see answer to @Avacsiglo21 reaction.
The info on the card and hover info on the markers is correct.

Unknown or grey income info: I merged three docs and the names of the countries did not always
match. I updated the world bank spreadsheet but not completely.

Update 01/05 => the data going into the map layer are correct. I’ve split up the drawing of polylines
and markercircles which results and far less errors in the colouring, but it still sometimes makes
mistakes, no idea why. Mistakes happen when you switch country, not always but sometimes. If you use
the toggle switch, it is corrected. That’s something for another day.

7 Likes

Wow – I absolutely love this app! I hadn’t checked in for a bit, and the progress you’ve made is amazing @Avacsiglo21 :rocket: Really impressive work – you can tell how much thought and care went into this. Keep it going!

The modal is very smart – letting users pick a destination country upfront really helps focus the experience and makes the whole dashboard easier to follow. The layout is clean, the KPIs are well-selected and the visuals look great.

Wanted to drop in a few suggestions (had to think really hard to find something though) – just ideas to consider, so feel free to skip anything that doesn’t resonate :blush:

  • Align cards inside modal: You might want to remove the “Main source: XXX” text and shorten the country names here, since it already appears throughout the dashboard. Leaving it out could make the cards look cleaner and help align the buttons across them. If you prefer keeping it, setting a fixed card height might help with card alignment.

  • Place main interaction button to the left: The “Select Migrants Destination” button could stand out more if it were on the left – that’s where our eyes naturally go first, so it might help draw attention to the main interaction point.

  • Bump selected country in typography hierarchy: You could make the selected country more visible by including it in the dashboard subtitle (e.g., “Exploring origins of migrants to the United States”). Right now, it’s only shown in the chart titles, but since it affects the whole dashboard, bumping it up in the hierarchy could make things clearer – and might even let you simplify some chart headers.

  • Chart selection: Since all charts are showing magnitude, simplifying might help the story land better. I’d personally drop the treemap – the small entries are tricky to read – and stick with the bar chart and sankey. They work well together: bar chart for clear comparison, sankey for visual flow. Maybe even try flipping the bar chart to horizontal so labels match the sankey order.

  • (Nice-to-have) If it’s doable, a continent-level drilldown in the bar chart would be really cool. Like, start with continents, then click into one to see countries. Kind of a best-of-both-worlds between treemap and bar chart – clean and informative.

Honestly, such great work :clap:

3 Likes

Hey @Ester – just to clarify, you’re looking to change the color scale because there isn’t much contrast, right? Most countries fall into the lower range (yellow), while a few outliers in the higher range (green) are skewing the scale. One thing you could try is dynamically adjusting the midpoint or the max. value of the color scale – that might help bring out more contrast between the countries and make the differences more visible :thinking:

2 Likes

Beatiful Marianne, what the issue the type of map?

1 Like