Figure Friday 2025 - week 52

In which country has “life expectancy at birth” changed the most?

Answer this question and a few others by using Plotly on the Worldbank life expectancy dataset.

Things to consider:

  • would you use a different graph than the one below?
  • would you like to tell a different data story using a Dash app?
  • how would you explore the data differently with Plotly Studio?

Below are screenshots of the app created by Plotly Studio on top of the dataset:

Prompt for the first bar chart:

Bar chart comparing regional life expectancy in latest year

Chart:
- Type: Bar
- X: Region (`Country Name` filtered to regional aggregates)
- Y: Life expectancy in years (`2023`)
- Color: Region or None (via Dropdown)

Data:
- Filtered to regional aggregates only (Africa, Asia, Europe, Americas, Oceania, Arab World, etc.)
- Latest year: 2023
- Sorted by life expectancy descending

Options:
- Dropdown to select year for comparison (2000, 2010, 2015, 2020, 2023) - Default 2023
- Dropdown to sort by (Life expectancy, Alphabetical) - Default Life expectancy
- Dropdown to include color coding (Region, None) - Default None

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 Worldbank for the data.

1 Like

Last one this year. I was triggered by “changed the most“ and asked ChatGPT for different interpretations and metrics.

This is what it became after some iterations:

App and code: PyCafe - Dash - In which country has "life expectancy at birth" changed the most?

Until next year! Marie-Anne (I’m not available on friday)

4 Likes

Great work Marieanne, happy new year.

UPDATE: connected the top 10/bottom 10 graphs to use raw data, normalized gain or percent gain. Updated screenshot and code in this message.

Here is a link to my app for week 52: https://d4305e22-eddd-4a40-b66c-bb2440f21141.plotly.app

Global Life Expectancy Dashboard
Interactive Visualization of Global Trends

Explore life expectancy at birth across countries and decades with this Dash application. Designed for intuitive analysis and deep insights, the dashboard offers:

Year Range Slider: All visualizations use data filtered by this range slider. It defaults to earliest and latest dates, so all data is used by default, and only filtered down when one or both ends of the range slider moves.

Timeline Plot: life expectancy trends by country & year, with options to show raw values, normalized data, or percentage change.

Country Focus: Highlight up to five countries for detailed comparison using bold colored markers while all other country traces are visually de-emphasized in gray.

Stacked Histogram: Distribution of life expectancy with data grouped by decade. The difference by decade is not easy to see, because the distributions are much wider than the mean values variations.

Box Plot: Compare life expectancy by decade. It shows what I tried to find in the histogram, that life expectancy mean values by decade are increasing, and that the spread is decreasing.

Choropleth Map: See average life expectancy by country on a global map.

Pareto Charts: Discover the top and bottom N countries ranked by average life expectancy, using engaging lollipop-style markers and horizontal layouts.

A point worth noting is that life expectancy increase have been dramatic in countries where the initial values were low in 1960. Afghanistan as an example had life expectancy increase from 32.8 years in 1960 to 66.0 years in 2023, an increase of more than 100%. Countries that started with life expectancies of 60 years or more (Canada, Switzerland, Japan and many others) have much less room to improve and show much less growth. Andorra improved from 72.1 years to 84.0 years, an improvement 16.6%. This is the nature of life expectancy data, where distributions are skewed, and often modeled with Gompertz, Weibull, or log-normal distributions.

Here is a screenshot. Check out the timeline plot with no focus countries selected, and then select any countries of interest for focus traces.

Here is a screenshot:

Here is the code:

"""
Global Life Expectancy Dashboard
================================
An interactive Dash application visualizing life expectancy data across
countries and decades.

Features:
- Timeline plot showing life expectancy trends by country
  (raw, normalized, or % change)
- Focus country highlighting for up to 4 countries
- Stacked histogram showing distribution by decade
- Box plot comparing decades
- Choropleth map showing average life expectancy by country
- Pareto charts displaying top/bottom N countries ranked by average
  life expectancy, with lollipop-style markers and horizontal orientation
- Range slider for filtering data by year range

Data Source: World Bank life expectancy at birth dataset
Date: December 2025
"""

# Data manipulation
import polars as pl
import polars.selectors as cs
import os

# Visualization
import plotly.express as px
import plotly.graph_objects as go

# Dashboard framework
from dash import Dash, dcc, html, Input, Output
import dash_mantine_components as dmc

#----- GLOBALS -----------------------------------------------------------------
root_file = 'live_expectancy_at_birth'  # Base filename for data (CSV/parquet)

# CSS styles for consistent UI elements throughout the dashboard
style_horizontal_thick_line = {'border': 'none', 'height': '4px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 32}
style_horizontal_thin_line = {'border': 'none', 'height': '2px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 12}

# Title text style
style_h2 = {'text-align': 'center', 'font-size': '40px', 
            'fontFamily': 'Arial','font-weight': 'bold', 'color': 'gray'}
style_h3 = {'text-align': 'center', 'font-size': '16px', 
            'fontFamily': 'Arial','font-weight': 'normal', 'color': 'gray'}
plotly_template = 'simple_white'  # Consistent Plotly template  

color_palette = px.colors.qualitative.Dark24  # Color palette for timeline traces`

#-----  VISUALIZATION FUNCTIONS ------------------------------------------------
# Each function generates a specific Plotly figure for the dashboard

def get_timeline_plot(df, plot_type, focus_country_codes) -> go.Figure:
    '''
    Generate a multi-line timeline plot showing life expectancy over time.
    
    When focus countries are selected, all other countries are grayed out
    and the focus countries are highlighted in red, blue, and green.
    '''
    # Visual parameters for traces
    marker_size = 5
    line_width = 1
    y_axis_label = ''  # Will be set based on plot_type
    # Transform data based on selected plot type
    if plot_type == 'Raw Data':
        # Show actual life expectancy values
        y_axis_label = 'Life Expectancy (Years)'
    elif plot_type == 'Norm Data':
        # Normalize: subtract first year's value from all years
        df = ( 
            df        
            .select(
                'YEAR',
                (cs.all().exclude('YEAR')-cs.all().exclude('YEAR').first()),
            )
        )
        y_axis_label = 'Cumulative Change (Years)'
    elif plot_type == 'PCT Change':
        # Percentage change relative to first year
        df = ( 
            df   
            .select(
                'YEAR',
                (  
                (cs.all().exclude('YEAR') - cs.all().exclude('YEAR').first()) 
                / cs.all().exclude('YEAR').first()
                )
            )
            .with_columns(cs.all().exclude('YEAR') * 100)
        )
        y_axis_label = 'Cumulative Change (%)'

    y_cols = [c for c in df.columns if c != 'YEAR']
    first_year = df['YEAR'].min()
    last_year = df['YEAR'].max()


    # Create figure with go.Scatter for each country
    fig = go.Figure()
    for i, col in enumerate(y_cols):
        color = color_palette[i % len(color_palette)]
        fig.add_trace(
            go.Scatter(
                x=df['YEAR'],
                y=df[col],
                name=col,
                mode='lines+markers',
                line=dict(color=color, width=line_width),
                marker=dict(size=marker_size, color=color),
                zorder=0,
            )
        )
    fig.update_layout(
        title=f'Life Expectancy Timeline by Country, {first_year} to {last_year}',
        template=plotly_template,
    )
    
    if any(focus_country_codes):
        # when focus countries are selected, gray out/de-emphasize all others
        fig.update_traces(
            mode='lines',
            line=dict(color='lightgray'), 
            showlegend=False,
            hoverinfo='none',
        )
        fig.update_layout(
            showlegend=True, 
            legend_title_text='Focus Countries',
            hovermode='x unified',
            legend=dict(
                orientation='h',
                yanchor='top',
                y=-0.2,
                xanchor='center',
                x=0.5
            ),
        )
        # Add highlighted traces for each focus country
        for i, code in enumerate(focus_country_codes):
            my_color = color_palette[i % len(color_palette)]
            fig.add_traces([
                go.Scatter(
                    x=df['YEAR'],
                    y=df[code],
                    name=get_country_name(code),
                    marker=dict(size=marker_size, color=my_color),
                    line=dict(width=line_width, color=my_color, dash='solid'),
                    mode='lines+markers',
                    showlegend=True,
                    zorder=1,
                    hovertemplate='%{fullData.name}: %{y:.1f}<extra></extra>',
                ),
            ])
    fig.update_xaxes(
        showticklabels=True,
        ticks='',
        showline=True,
        title_text='',
        showgrid=True,
    )
    fig.update_yaxes(
        showticklabels=True,
        ticks='',
        showline=True,
        title_text=y_axis_label
    )
    return fig

def get_histogram(df) -> go.Figure:
    '''
    Generate a stacked histogram showing life expectancy distribution by decade.
    
    Data is unpivoted (melted) to create a long-format DataFrame with
    DECADE and value columns for histogram binning.
    '''
    # Reshape data: add DECADE column and unpivot country columns to rows
    df_melt = (
        df
        .with_columns(DECADE=(pl.col('YEAR').cast(pl.Utf8).str.slice(0, 3) + '0s'))
        .unpivot(on=cs.all().exclude(['YEAR', 'DECADE']),index='DECADE')
    )

    # Create figure with go.Histogram for each decade
    fig = go.Figure()
    decades = sorted(df_melt['DECADE'].unique().to_list())
    first_decade = decades[0]
    last_decade = decades[-1]
    # Define consistent bin edges for all histograms
    bin_start = 20
    bin_end = 95
    bin_size = (bin_end - bin_start) / 25
    for decade in decades:
        df_decade = df_melt.filter(pl.col('DECADE') == decade)
        fig.add_trace(
            go.Histogram(
                x=df_decade['value'],
                name=decade,
                xbins=dict(start=bin_start, end=bin_end, size=bin_size),
            )
        )
    fig.update_layout(
        title=dict(
            text='Life Expectancy by Decade',
            subtitle=dict(text=f'{first_decade} to {last_decade}'),
        ),
        xaxis_title='Life Expectancy (Years)',
        template=plotly_template,
        barmode='stack',
        bargap=0.1,
    )
    fig.update_traces(opacity=0.5)
    fig.update_xaxes(
        showticklabels=True,
        ticks='',
        showline=True,
        title_text='Life Expectancy (Years)',
        showgrid=True,
        range=[20, 90],
    )
    return fig

def get_boxplot(df) -> go.Figure:
    '''
    Generate vertical box plots comparing life expectancy distribution by decade.
    
    Box plots show median, quartiles, and outliers. Mean line is also displayed.
    '''
    # Reshape data: add DECADE column and unpivot country columns to rows
    df_melt = (
        df
        .with_columns(DECADE=(pl.col('YEAR').cast(pl.Utf8).str.slice(0, 3) + '0s'))
        .unpivot(on=cs.all().exclude(['YEAR', 'DECADE']),index='DECADE')
    )

    # Create figure 
    fig = go.Figure()
    decades = sorted(df_melt['DECADE'].unique().to_list())
    first_decade = decades[0]
    last_decade = decades[-1]
    # Define consistent bin edges for all histograms
    for decade in decades:
        df_decade = df_melt.filter(pl.col('DECADE') == decade)
        fig.add_trace(
            go.Box(
                y=df_decade['value'],
                name=decade,
                boxmean=True,
            )
        )
    fig.update_layout(
        title=dict(
            text='Life Expectancy by Decade',
            subtitle=dict(text=f'{first_decade} to {last_decade}'),
        ),
        yaxis_title='Life Expectancy (Years)',
        template=plotly_template,
    )
    fig.update_yaxes(
        showticklabels=True,
        ticks='',
        showline=True,
        showgrid=True,
        range=[20, 90],
        title_text='Life Expectancy (Years)',
    )
    return fig

def get_choropleth(df) -> go.Figure:
    '''
    Generate a world choropleth map showing average life expectancy by country.
    
    Data is transposed back to country rows, then average life expectancy
    is calculated across all years for each country.
    '''
    first_year = df['YEAR'].min()
    last_year = df['YEAR'].max()
    fig = go.Figure()
    df_transposed = (
        df
        .with_columns(pl.col('YEAR').cast(pl.String))
        .transpose(
            include_header=True, 
            column_names='YEAR', 
        )
        .with_columns(
            MEAN = pl.mean_horizontal(cs.float())
        )
        .select(
            COUNTRY_CODE = pl.col('column'),
            MEAN = pl.col('MEAN')
        )
        .join(
            df_country_codes, on='COUNTRY_CODE', how='left'
        )
    )

    fig = px.choropleth(
        df_transposed, 
        locations="COUNTRY_CODE",
        color="MEAN", 
        hover_name="COUNTRY_CODE", # column to add to hover information
        color_continuous_scale=px.colors.sequential.Plasma,
        custom_data=['COUNTRY_CODE', 'COUNTRY_NAME', 'MEAN'],
        title='Average Life Expectancy by Country',
        subtitle=f'{first_year} to {last_year}',
        projection='winkel tripel',
        )
    
    fig.update_traces(
        hovertemplate=(
            '<b>%{customdata[0]}</b><br>' +   
            '<b>%{customdata[1]}</b><br>' +   
            '%{customdata[2]:.1f} years<extra></extra>'
        )
    )  
    return fig

def get_pareto(df, plot_type: str, category: str, top_n: int) -> go.Figure:
    '''
    Create a Pareto chart showing top or bottom countries by life expectancy.

    Generates a horizontal lollipop chart ranking countries by their mean
    life expectancy across all available years. Countries are sorted and
    displayed with markers and connecting lines.
    '''
    first_year = df['YEAR'].min()
    last_year = df['YEAR'].max()
    fig = go.Figure()
    df_transposed = (
        df
        .with_columns(pl.col('YEAR').cast(pl.String))
        .transpose(
            include_header=True, 
            column_names='YEAR', 
        )
        .select(
            COUNTRY_CODE = pl.col('column'),
            MEAN = pl.mean_horizontal(cs.float()),
            GAIN = (
                pl.concat_list(cs.float()).list.last() - 
                pl.concat_list(cs.float()).list.first()
            ),
            PCT_GAIN = (
                (pl.concat_list(cs.float()).list.last() - 
                pl.concat_list(cs.float()).list.first()) /
                pl.concat_list(cs.float()).list.first() * 100
            ),
        )      
        .join(
            df_country_codes, on='COUNTRY_CODE', how='left'
        )

        .filter(pl.col('MEAN').is_not_null())
        .filter(pl.col('MEAN').is_not_null())
        .filter(pl.col('PCT_GAIN').is_not_null())
    )
    df_transposed.write_csv('debug_pareto.csv')  # Debug output
    if plot_type == 'Raw Data':
        x_cat = 'MEAN'
        my_title=(
            f'{"Top" if category=="TOP" else "Bottom"} ' +
            f'{top_n} Average Life Expectancies'
        )
        my_xaxis_title='Avg. Life Expectancy (Years)'
    elif plot_type == 'Norm Data':
        x_cat = 'GAIN'
        my_title=(
            f'{"Top" if category=="TOP" else "Bottom"} ' +
            f'{top_n} Life Expectancy Gains (Years)'
        )
        my_xaxis_title='Life Expectancy Gains (Years)'
    elif plot_type == 'PCT Change':
        x_cat = 'PCT_GAIN'
        my_title=(
            f'{"Top" if category=="TOP" else "Bottom"} ' +
            f'{top_n} Life Expectancy Gains (%)'
        )
        my_xaxis_title='Life Expectancy Gain (%)'
    

    my_subtitle=f'{first_year} to {last_year}'

    if category == 'TOP':
        df_pareto = df_transposed.sort(x_cat, descending=True).head(top_n)
    elif category == 'BOTTOM':
        df_pareto = df_transposed.sort(x_cat, descending=False).head(top_n)


    # Get country names in reverse order for y-axis (so highest/lowest appears at top)
    country_order = df_pareto['COUNTRY_NAME'].to_list()
    
    fig=px.scatter(
        df_pareto,
        y='COUNTRY_NAME',
        x=x_cat,
        template=plotly_template,
        title=my_title,
        subtitle=my_subtitle,
        custom_data=['COUNTRY_CODE', 'COUNTRY_NAME', 'MEAN', 'GAIN', 'PCT_GAIN'],
        category_orders={'COUNTRY_NAME': country_order},
        text='COUNTRY_NAME',
    )
    labels = df_pareto['COUNTRY_NAME'].to_list()
    fig.update_traces(
        hovertemplate=(
            '<b>%{customdata[0]}</b><br>' +   
            '<b>%{customdata[1]}</b><br><br>' +  
            '<b>MEAN:</b>%{customdata[2]:.3f}<br>' +
            '<b>GAIN:</b>%{customdata[3]:.3f}<br>' + 
            '<b>PCT_GAIN:</b>%{customdata[4]:.1f} %<extra></extra>'
        ),
        mode='lines+markers+text',
        text=labels,                # Text labels for each point
        textposition='top right',  # Position of text relative to markers
        marker=dict(size=10, color='blue'),
        line=dict(color='lightgray', width=2),
    )  

    pareto_min = df_pareto[x_cat].min()
    pareto_max = df_pareto[x_cat].max()
    pareto_margin = 0.05 * (pareto_max - pareto_min)
    
    # Calculate 6 evenly-spaced tick positions
    tick_step = (pareto_max - pareto_min) / 5  # 5 intervals for 6 ticks
    tick_vals = [pareto_min + i * tick_step for i in range(6)]
    
    if category == 'TOP':
        fig.update_xaxes(range=
            [pareto_max + pareto_margin, pareto_min - 8*pareto_margin]
        )
    elif category == 'BOTTOM':
        fig.update_xaxes(range=
            [pareto_min - pareto_margin, pareto_max + 8*pareto_margin]
        )
    # hide y-axis(country name), since it's shown as a text label on each point
    fig.update_yaxes(
        showticklabels=False,
        ticks='',
        showline=False,
        title_text='',
        showgrid=False,
    )
    fig.update_xaxes(
        showgrid=True,
        tickmode='array',
        tickvals=tick_vals,
        tickformat='.1f',
        title_text=my_xaxis_title,
    )
    
    return fig


#----- HELPER FUNCTIONS --------------------------------------------------------
# Utility functions for country code/name lookups

def get_country_code(country_name: str) -> str:
    '''Look up 3-letter country code from full country name.'''
    code = (
        df_country_codes
        .filter(pl.col('COUNTRY_NAME') == country_name)
        .select('COUNTRY_CODE')
        .item(0, 'COUNTRY_CODE')
    )
    return code

def get_country_name(country_code: str) -> str:
    '''Look up full country name from 3-letter country code.'''
    name = (
        df_country_codes
        .filter(pl.col('COUNTRY_CODE') == country_code)
        .select('COUNTRY_NAME')
        .item(0, 'COUNTRY_NAME')
    )
    return name

#----- LOAD AND CLEAN DATA -----------------------------------------------------
# Load data from parquet (fast) if available, otherwise from CSV
# Parquet is cached after first CSV read for faster subsequent loads

if os.path.exists(root_file + '.parquet'):
# if False:  # Uncomment to force re-read from CSV
    print(f'{"*"*20} Reading {root_file}.parquet  {"*"*20}')
    df = pl.read_parquet(root_file + '.parquet')
  
else:
    print(f'{"*"*20} Reading {root_file}.csv  {"*"*20}')
    df = (
        pl.read_csv(root_file + '.csv', ignore_errors=True)
        .drop('Indicator Name')  # Not needed
        .rename({'Country Code': 'COUNTRY_CODE','Country Name':'COUNTRY_NAME'})
        .sort('COUNTRY_CODE')
    )
    df.write_parquet(root_file + '.parquet')  # Cache for next time

# Lookup table: country code <-> country name mapping
df_country_codes = (
    df.select(cs.starts_with('COUNTRY_'))
)

# Transpose data: rows become years, columns become country codes
# This format is needed for timeline plotting (one trace per country)
df_transposed = (
    df
    .drop('COUNTRY_NAME')
    .transpose(
        include_header=True, 
        column_names='COUNTRY_CODE', 
        header_name='YEAR'
    )
    .with_columns(YEAR=pl.col('YEAR').cast(pl.UInt16))  # Year as integer
    .with_columns(cs.all().exclude('YEAR').cast(pl.Float32))  # Values as float
)

#----- GLOBAL LISTS ------------------------------------------------------------

plot_types = ['Raw Data', 'Norm Data', 'PCT Change']  # Timeline view options
all_country_names = sorted(df_country_codes.unique('COUNTRY_NAME')['COUNTRY_NAME'].to_list())  # Dropdown options
all_country_codes = list(df_country_codes['COUNTRY_CODE'])
year_min = int(df_transposed['YEAR'].min())  # Slider bounds
year_max = int(df_transposed['YEAR'].max())

#----- DASH COMPONENTS ---------------------------------------------------------
# Reusable UI components defined separately for cleaner layout code

# Radio buttons for selecting timeline data transformation
dcc_plot_type = (
    dmc.RadioGroup(
        children=dmc.Stack([dmc.Radio(label=pt, value=pt) for pt in plot_types]),
        value=plot_types[0],  # Default: Raw Data
        id='id_select_plot_type'
    )
)

# Year range slider - filters ALL visualizations
dmc_year_range_slider = (
    dmc.RangeSlider(
        id='id_year_range_slider',
        value=[year_min, year_max],  # Default: full range
        min=year_min,
        max=year_max,
        step=1,
        marks=[  # Show labels every 5 years
            {'value': y, 'label': str(y)} 
            for y in range(year_min, year_max + 1) 
            if y % 5 == 0
        ],
    )
)

# Focus country dropdowns (pick up to 5 countries to highlight on timeline)
dcc_focus_countries = (
    dcc.Dropdown(
        placeholder='Select Country', 
        options=[{'label': name, 'value': name} for name in all_country_names],
        value=['Afghanistan','Canada', 'Bangladesh'],  # Default selection
        style={'fontSize': '18px', 'color': 'black'},
        id='id_focus_countries',multi=True
    )
)

#----- DASH APPLICATION STRUCTURE ----------------------------------------------
# Main application layout using Dash Mantine Components grid system
app_banner='Global Life Expectancy'
app_sub_banner = (
    'An interactive Dash application visualizing worldwide life expectancy ' +
     ' Data Source: World Bank'
)
app = Dash()
server = app.server  # For deployment (e.g., Gunicorn)
app.layout = dmc.MantineProvider([
    html.Hr(style=style_horizontal_thick_line),
    dmc.Text(app_banner, ta='center', style=style_h2),
    dmc.Space(h=10),
    dmc.Text(app_sub_banner, ta='center', style=style_h3),
    html.Hr(style=style_horizontal_thick_line),
    dmc.Space(h=30),
    dmc.Grid(children =  [
        dmc.GridCol(
            dmc.Text('Timeline, Pareto Data Type', ta='left'), span=2, offset=0),
        dmc.GridCol(dmc.Text(
            'Year Range Slider - Filters all visualizations', ta='left'), 
            span=6, offset=2
        ),
    ]),
    dmc.Space(h=10),
    dmc.Grid(
        children = [  
            dmc.GridCol(html.Div(dcc_plot_type), span=2, offset=0),
            dmc.GridCol(dmc_year_range_slider, span=6, offset=2),
        ],
    ),
    dmc.Space(h=30),
        dmc.Grid(children =  [
            dmc.GridCol(
                dmc.Text('Timeline Focus Countries, pick up to 5', ta='left'), 
                span=4, offset=0
                ),
    ]),
    dmc.Grid(
        children = [  
            dmc.GridCol(html.Div(dcc_focus_countries), span=4, offset=0),
            dmc.GridCol(dmc.Text('Pareto Graphs', ta='left'), span=4, offset=1),
        ],
    ),
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='timeline_plot'), span=4,offset=0), 
        dmc.GridCol(dcc.Graph(id='highest-10-expectancy'), span=4, offset=0),
        dmc.GridCol(dcc.Graph(id='lowest-10-expectancy'), span=4, offset=0),      
    ]),
    dmc.Grid(children = [
        dmc.GridCol(dcc.Graph(id='choropleth'), span=4, offset=0), 
        dmc.GridCol(dcc.Graph(id='histogram'), span=4, offset=0),  
        dmc.GridCol(dcc.Graph(id='boxplot'), span=4, offset=0), 
    ]),
])

#----- CALLBACK ----------------------------------------------------------------
# Main callback: updates all visualizations when any input changes

@app.callback(
    Output('timeline_plot', 'figure'),
    Output('histogram', 'figure'),
    Output('boxplot', 'figure'),
    Output('choropleth', 'figure'),
    Output('highest-10-expectancy', 'figure'),
    Output('lowest-10-expectancy', 'figure'),
    Output('id_focus_countries', 'value'),
    Input('id_select_plot_type', 'value'),
    Input('id_year_range_slider', 'value'),
    Input('id_focus_countries', 'value'),
)
def callback(selected_plot_type, year_range, focus_countries):
    '''Main callback: filter data and regenerate all figures.'''

    max_selections = 5
    if focus_countries and len(focus_countries) > max_selections:
        focus_countries = focus_countries[:max_selections]

    focus_country_codes = [
        get_country_code(country) for country in focus_countries]

    # Filter data by selected year range
    df = (
        df_transposed
        .filter(pl.col('YEAR').is_between(year_range[0], year_range[1]))
    )
    
    # Generate all visualizations
    timeline_plot = get_timeline_plot(df, selected_plot_type,  focus_country_codes)

    histogram = get_histogram(df)
    boxplot = get_boxplot(df)
    choropleth = get_choropleth(df)
    top_10 = get_pareto(df, selected_plot_type, 'TOP', 10) 
    bottom_10 = get_pareto(df, selected_plot_type, 'BOTTOM', 10)
    
    return (timeline_plot, histogram, boxplot, choropleth, 
        top_10, bottom_10, focus_countries)


#----- MAIN --------------------------------------------------------------------
if __name__ == '__main__':
    app.run(debug=True)  # debug=True enables hot-reload during development
5 Likes

@marieanne I really like the relative change % chart. The improvements in life expectancy that China made are impressive. What exactly did you ask ChatGTP to help you create these two charts?

1 Like

@Mike_Purtell that was a smart decision to gray out the lines of all the other countries.

In this graph of average life expectancy from 1960-2023, could we conclude that these 10 countries are the most stably high-life-expectancy countries? Not necessarily the highest life expectancy.

1 Like

@adamschroeder this is how the conversation started after some reshaping of the df, chatGPT was aware of the df structure, and probably a lot more:

Summary

OK, if this is the question: In which country has “life expectancy at birth” changed the most? What are ways to interprete this question and how to visualize the result?

This is a great question, because “changed the most” is ambiguous, and different interpretations can lead to very different answers. Being explicit about the interpretation is crucial — especially if this ends up in a dashboard or report.

Below I’ll break it down in a structured, practical way, and for each interpretation I’ll suggest appropriate visualizations.


:one: Net change over the full period (start → end)

Interpretation

Which country experienced the largest overall increase (or decrease) in life expectancy between the first and last year in the dataset?

Metric

Δ = LifeExpectancy(last year) − LifeExpectancy(first year)

You can also consider:

  • absolute change (years gained/lost)

  • signed change (to see improvements vs declines)

Visualization options

Best

  • Horizontal bar chart

    • Sorted by absolute change

    • Color positive vs negative

Alternatives

  • Dot plot (start vs end)

  • Slopegraph (top N countries)

:bar_chart: Good for answering: “Which countries improved the most?”


:two: Largest absolute year-to-year change (volatility)

Interpretation

Which country had the biggest single jump or drop in life expectancy in any one year?

Metric

max(|LE_t − LE_(t−1)|)

This captures:

  • Pandemics

  • Wars

  • Data anomalies

  • Health crises

Visualization options

Best

  • Bar chart of maximum annual change per country

Contextual

  • Line chart for the top country only

  • Annotated time series showing the spike

:warning: Often highlights outliers or reporting changes


:three: Long-term trend (rate of change)

Interpretation

Which country’s life expectancy changed the fastest over time?

Metric

Fit a trend:

slope of linear regression (years per calendar year)

This avoids sensitivity to noisy endpoints.

Visualization options

Best

  • Bar chart of trend slopes

Advanced

  • Scatter plot:

    • x = starting life expectancy

    • y = trend slope

:chart_increasing: Good for structural improvements


:four: Relative (percentage) change

Interpretation

Which country improved the most relative to where it started?

Metric

(L_end − L_start) / L_start

This often favors:

  • Countries starting from low life expectancy

Visualization options

Best

  • Bar chart (percentage change)

Alternative

  • Scatter plot:

    • x = initial life expectancy

    • y = % change

:pushpin: Important for equity-focused analyses


:five: Total accumulated change (area under change curve)

Interpretation

Which country experienced the greatest cumulative improvement over time?

Metric

Sum of year-to-year changes:

Σ (LE_t − LE_(t−1))

Equivalent to endpoint difference only if monotonic, but not otherwise.

Visualization options

  • Ranked bar chart

  • Line chart + shaded improvement area (for top N)


:six: Stability vs change (bonus insight)

Interpretation

Which countries changed the most vs stayed stable?

Metric

  • Standard deviation of year-to-year changes

Visualization options

  • Scatter:

    • x = mean life expectancy

    • y = volatility


:compass: Recommended way to present this

If this were my dashboard (and given your KPI-heavy Dash background :wink:):

Step 1 — Be explicit

“Changed the most” measured as:
Net increase in life expectancy between 1960–2022

Step 2 — Primary chart

  • Horizontal bar chart (Top 10 countries)

Step 3 — Context chart

  • Time series lines for those top 10

  • Tooltip shows start, end, Δ

Step 4 — Add a toggle (optional)

Let users switch between:

  • Net change

  • Trend

  • Volatility

  • % change


:warning: Pitfalls to call out (important)

  • Missing years can bias endpoints

  • Political changes (e.g. USSR split) distort trends

  • Data revisions can create artificial jumps

  • Pandemic years (e.g. 2020–2021) dominate volatility metrics


TL;DR (executive summary)

Interpretation Answers the question Best chart
Net change Who improved most overall Bar chart
Max annual change Biggest shock Bar + line
Trend Fastest improvement Bar
% change Relative progress Bar
Volatility Stability vs chaos Scatter

If you want, I can:

  • Write clean Pandas code for each metric

  • Propose a Dash layout with metric toggles

  • Help you phrase this unambiguously for publication

The chart(s) on the left are number 1 to 4

The chart in the right column is number 6, after some ask and answer.

The lay-out was my idea, actually more was my idea but hey. :slight_smile: And yes, I had the “Hey, look at China“ too.

2 Likes

Hi @adamschroeder , those 10 countries have the highest life expectancy mean values over the window of years, but not necessarily the most stable, and not the most recent. Recency can be improved by setting the range slider to 2013-2023

The Top 10/ Bottom 10 graphs only plot life expectancy. I will post an update where these top 10 graphs take the plot type selected and display either top/bottom life expectancy (as it does now), top/bottom life expectancy gain in years, and top/bottom life expectancy percent gain. Thank you

1 Like

Life Expectancy Dashboard

:key: Claude AI–powered application created in VS Code using the Claude extension, built with Dash (Plotly) and Pandas.
:broom: Only country-level data is used: aggregated regions are removed, and countries are grouped into seven clear world regions to make comparisons easier.
:control_knobs: Simple interactive controls (year and region selection) let users explore the data, while all key numbers update instantly (average, median, highest, lowest, long-term change).
:globe_showing_europe_africa: The main view is a world map colored by life expectancy, supported by a ranked country list that helps users quickly compare countries.

Code
import pandas as pd

import dash

from dash import dcc, html, Input, Output

import dash_bootstrap_components as dbc

import plotly.express as px

import numpy as np

df = pd.read_csv(“live_expectancy_at_birth.csv”)

years = \[c for c in df.columns if c.isdigit()\]

REGIONS = \[


"East Asia & Pacific",

"Europe & Central Asia",

"Latin America & Caribbean",

"Middle East & North Africa",

"North America",

"South Asia",

"Sub-Saharan Africa",

\]

COUNTRY_TO_REGION = {

\# East Asia & Pacific

"Australia": "East Asia & Pacific",

"New Zealand": "East Asia & Pacific",

"Japan": "East Asia & Pacific",

"Korea, Rep.": "East Asia & Pacific",

"China": "East Asia & Pacific",

"Mongolia": "East Asia & Pacific",

"Indonesia": "East Asia & Pacific",

"Thailand": "East Asia & Pacific",

"Viet Nam": "East Asia & Pacific",

"Philippines": "East Asia & Pacific",

"Malaysia": "East Asia & Pacific",

"Singapore": "East Asia & Pacific",

"Myanmar": "East Asia & Pacific",

"Cambodia": "East Asia & Pacific",

"Lao PDR": "East Asia & Pacific",

"Brunei Darussalam": "East Asia & Pacific",

"Timor-Leste": "East Asia & Pacific",

"Papua New Guinea": "East Asia & Pacific",

"Fiji": "East Asia & Pacific",

"Solomon Islands": "East Asia & Pacific",

"Vanuatu": "East Asia & Pacific",

"Samoa": "East Asia & Pacific",

"Tonga": "East Asia & Pacific",

"Kiribati": "East Asia & Pacific",

"Micronesia, Fed. Sts.": "East Asia & Pacific",

"Palau": "East Asia & Pacific",

"Marshall Islands": "East Asia & Pacific",

"Nauru": "East Asia & Pacific",

"Tuvalu": "East Asia & Pacific",

"Hong Kong SAR, China": "East Asia & Pacific",

"Macao SAR, China": "East Asia & Pacific",

"Korea, Dem. People's Rep.": "East Asia & Pacific",

\# Europe & Central Asia

"United Kingdom": "Europe & Central Asia",

"Germany": "Europe & Central Asia",

"France": "Europe & Central Asia",

"Italy": "Europe & Central Asia",

"Spain": "Europe & Central Asia",

"Poland": "Europe & Central Asia",

"Romania": "Europe & Central Asia",

"Netherlands": "Europe & Central Asia",

"Belgium": "Europe & Central Asia",

"Czechia": "Europe & Central Asia",

"Greece": "Europe & Central Asia",

"Portugal": "Europe & Central Asia",

"Sweden": "Europe & Central Asia",

"Hungary": "Europe & Central Asia",

"Austria": "Europe & Central Asia",

"Switzerland": "Europe & Central Asia",

"Bulgaria": "Europe & Central Asia",

"Denmark": "Europe & Central Asia",

"Finland": "Europe & Central Asia",

"Norway": "Europe & Central Asia",

"Ireland": "Europe & Central Asia",

"Croatia": "Europe & Central Asia",

"Slovak Republic": "Europe & Central Asia",

"Lithuania": "Europe & Central Asia",

"Slovenia": "Europe & Central Asia",

"Latvia": "Europe & Central Asia",

"Estonia": "Europe & Central Asia",

"Cyprus": "Europe & Central Asia",

"Luxembourg": "Europe & Central Asia",

"Malta": "Europe & Central Asia",

"Iceland": "Europe & Central Asia",

"Albania": "Europe & Central Asia",

"North Macedonia": "Europe & Central Asia",

"Serbia": "Europe & Central Asia",

"Bosnia and Herzegovina": "Europe & Central Asia",

"Montenegro": "Europe & Central Asia",

"Kosovo": "Europe & Central Asia",

"Moldova": "Europe & Central Asia",

"Ukraine": "Europe & Central Asia",

"Belarus": "Europe & Central Asia",

"Russian Federation": "Europe & Central Asia",

"Turkiye": "Europe & Central Asia",

"Kazakhstan": "Europe & Central Asia",

"Uzbekistan": "Europe & Central Asia",

"Turkmenistan": "Europe & Central Asia",

"Tajikistan": "Europe & Central Asia",

"Kyrgyz Republic": "Europe & Central Asia",

"Azerbaijan": "Europe & Central Asia",

"Georgia": "Europe & Central Asia",

"Armenia": "Europe & Central Asia",

"Andorra": "Europe & Central Asia",

"Monaco": "Europe & Central Asia",

"San Marino": "Europe & Central Asia",

"Liechtenstein": "Europe & Central Asia",

\# Latin America & Caribbean

"Brazil": "Latin America & Caribbean",

"Mexico": "Latin America & Caribbean",

"Argentina": "Latin America & Caribbean",

"Colombia": "Latin America & Caribbean",

"Peru": "Latin America & Caribbean",

"Venezuela, RB": "Latin America & Caribbean",

"Chile": "Latin America & Caribbean",

"Ecuador": "Latin America & Caribbean",

"Guatemala": "Latin America & Caribbean",

"Cuba": "Latin America & Caribbean",

"Bolivia": "Latin America & Caribbean",

"Dominican Republic": "Latin America & Caribbean",

"Honduras": "Latin America & Caribbean",

"Paraguay": "Latin America & Caribbean",

"Nicaragua": "Latin America & Caribbean",

"El Salvador": "Latin America & Caribbean",

"Costa Rica": "Latin America & Caribbean",

"Panama": "Latin America & Caribbean",

"Uruguay": "Latin America & Caribbean",

"Jamaica": "Latin America & Caribbean",

"Trinidad and Tobago": "Latin America & Caribbean",

"Guyana": "Latin America & Caribbean",

"Suriname": "Latin America & Caribbean",

"Belize": "Latin America & Caribbean",

"Bahamas, The": "Latin America & Caribbean",

"Barbados": "Latin America & Caribbean",

"Haiti": "Latin America & Caribbean",

"St. Lucia": "Latin America & Caribbean",

"Grenada": "Latin America & Caribbean",

"St. Vincent and the Grenadines": "Latin America & Caribbean",

"Antigua and Barbuda": "Latin America & Caribbean",

"Dominica": "Latin America & Caribbean",

"St. Kitts and Nevis": "Latin America & Caribbean",

\# Middle East & North Africa

"Egypt, Arab Rep.": "Middle East & North Africa",

"Iran, Islamic Rep.": "Middle East & North Africa",

"Iraq": "Middle East & North Africa",

"Saudi Arabia": "Middle East & North Africa",

"Yemen, Rep.": "Middle East & North Africa",

"Syrian Arab Republic": "Middle East & North Africa",

"Jordan": "Middle East & North Africa",

"United Arab Emirates": "Middle East & North Africa",

"Israel": "Middle East & North Africa",

"Lebanon": "Middle East & North Africa",

"Libya": "Middle East & North Africa",

"Tunisia": "Middle East & North Africa",

"Morocco": "Middle East & North Africa",

"Algeria": "Middle East & North Africa",

"Oman": "Middle East & North Africa",

"Kuwait": "Middle East & North Africa",

"Qatar": "Middle East & North Africa",

"Bahrain": "Middle East & North Africa",

"West Bank and Gaza": "Middle East & North Africa",

"Djibouti": "Middle East & North Africa",

\# North America

"United States": "North America",

"Canada": "North America",

\# South Asia

"India": "South Asia",

"Pakistan": "South Asia",

"Bangladesh": "South Asia",

"Afghanistan": "South Asia",

"Nepal": "South Asia",

"Sri Lanka": "South Asia",

"Bhutan": "South Asia",

"Maldives": "South Asia",

\# Sub-Saharan Africa

"Nigeria": "Sub-Saharan Africa",

"Ethiopia": "Sub-Saharan Africa",

"Congo, Dem. Rep.": "Sub-Saharan Africa",

"South Africa": "Sub-Saharan Africa",

"Tanzania": "Sub-Saharan Africa",

"Kenya": "Sub-Saharan Africa",

"Uganda": "Sub-Saharan Africa",

"Sudan": "Sub-Saharan Africa",

"Ghana": "Sub-Saharan Africa",

"Mozambique": "Sub-Saharan Africa",

"Madagascar": "Sub-Saharan Africa",

"Cote d'Ivoire": "Sub-Saharan Africa",

"Cameroon": "Sub-Saharan Africa",

"Niger": "Sub-Saharan Africa",

"Burkina Faso": "Sub-Saharan Africa",

"Mali": "Sub-Saharan Africa",

"Malawi": "Sub-Saharan Africa",

"Zambia": "Sub-Saharan Africa",

"Senegal": "Sub-Saharan Africa",

"Chad": "Sub-Saharan Africa",

"Somalia, Fed. Rep.": "Sub-Saharan Africa",

"Zimbabwe": "Sub-Saharan Africa",

"Guinea": "Sub-Saharan Africa",

"Rwanda": "Sub-Saharan Africa",

"Benin": "Sub-Saharan Africa",

"Burundi": "Sub-Saharan Africa",

"South Sudan": "Sub-Saharan Africa",

"Togo": "Sub-Saharan Africa",

"Sierra Leone": "Sub-Saharan Africa",

"Congo, Rep.": "Sub-Saharan Africa",

"Liberia": "Sub-Saharan Africa",

"Central African Republic": "Sub-Saharan Africa",

"Mauritania": "Sub-Saharan Africa",

"Eritrea": "Sub-Saharan Africa",

"Namibia": "Sub-Saharan Africa",

"Gambia, The": "Sub-Saharan Africa",

"Botswana": "Sub-Saharan Africa",

"Gabon": "Sub-Saharan Africa",

"Lesotho": "Sub-Saharan Africa",

"Guinea-Bissau": "Sub-Saharan Africa",

"Equatorial Guinea": "Sub-Saharan Africa",

"Mauritius": "Sub-Saharan Africa",

"Eswatini": "Sub-Saharan Africa",

"Comoros": "Sub-Saharan Africa",

"Cabo Verde": "Sub-Saharan Africa",

"Sao Tome and Principe": "Sub-Saharan Africa",

"Seychelles": "Sub-Saharan Africa",

}

df_regions = df\[df\[“Country Name”\].isin(REGIONS)\].copy()

aggregated_names = \[

"World",

"High income",

"Low income",

"Lower middle income",

"Upper middle income",

"Middle income",

"OECD members",

"European Union",

"Euro area",

"Arab World",

"IDA",

"IBRD",

"Small states",

"Fragile",

"Heavily indebted",

"Least developed",

"Late-demographic",

"Early-demographic",

"Post-demographic",

"Pre-demographic",


\]

df_countries = df\[

\~df\["Country Name"\].str.contains("|".join(aggregated_names), case=False, na=False)


\]

df_countries = df_countries\[\~df_countries\[“Country Name”\].isin(REGIONS)\]

df_countries = df_countries\[

df_countries[“Country Name”].isin(COUNTRY_TO_REGION.keys())


\].copy()

df_countries\[“Region”\] = df_countries\[“Country Name”\].map(COUNTRY_TO_REGION)

year_marks = {i: years\[i\] for i in range(0, len(years), 10)}

year_marks\[len(years) - 1\] = years\[-1\]

app = dash.Dash(\_*name*\_, external_stylesheets=\[dbc.themes.DARKLY\])

def create_kpi_card(title, value, icon, color):


return dbc.Card(

    \[

        dbc.CardBody(

            \[

                html.Div(

                    \[

                        html.I(

                            className=f"fas {icon} fa-2x", style={"color": color}

                        ),

                        html.Div(

                            \[

                                html.P(

                                    title,

                                    className="text-muted mb-0",

                                    style={"fontSize": "0.85rem"},

                                ),

                                html.H4(

                                    value,

                                    className="mb-0 fw-bold",

                                    style={"color": color},

                                ),

                            \],

                            style={"marginLeft": "15px"},

                        ),

                    \],

                    style={"display": "flex", "alignItems": "center"},

                )

            \]

        )

    \],

    className="shadow-sm",

    style={"backgroundColor": "#2d2d2d", "border": "none"},

)


dark_select_style = {


"backgroundColor": "#2d2d2d",

"color": "#ffffff",

"border": "1px solid #555",

"borderRadius": "6px",

"padding": "6px 10px",

"height": "38px",

"cursor": "pointer",

"appearance": "none",

"WebkitAppearance": "none",

"MozAppearance": "none",

"outline": "none",

}

app.layout = dbc.Container(

\[

    \# Header

    dbc.Row(

        \[

            dbc.Col(

                \[

                    html.H2(

                        "Life Expectancy Dashboard",

                        className="mb-0 fw-bold",

                        style={"color": "#fff", "paddingTop": "20px"},

                    ),

                    html.P(

                        "Global Life Expectancy Analysis", className="text-muted"

                    ),

                \],

                width=6,

            ),

            dbc.Col(

                \[

                    html.Div(

                        \[

                            dbc.Label("Year", className="text-light me-2"),

                            dbc.Select(

                                id="year-dropdown",

                                options=\[

                                    {"label": y, "value": y} for y in years

                                \],

                                value=years\[-1\],

                                style=dark_select_style,

                            ),

                        \],

                        style={

                            "display": "flex",

                            "alignItems": "right",

                            "justifyContent": "flex-end",

                            "paddingTop": "25px",

                            "gap": "10px",

                        },

                    )

                \],

                width=2, style={"textAlign": "right"}

            ),

        \]

    ),

    \# KPI Cards

    dbc.Row(

        \[

            dbc.Col(id="kpi-1", width=2),

            dbc.Col(id="kpi-2", width=2),

            dbc.Col(id="kpi-3", width=2),

            dbc.Col(id="kpi-4", width=2),

            dbc.Col(id="kpi-5", width=2),

            dbc.Col(id="kpi-6", width=2),

        \],

        className="mb-4",

    ),

    \# Filters Row

    dbc.Row(

        \[

            dbc.Col(

                \[

                    dbc.Label("Region", className="text-light"),

                    dbc.Select(

                        id="region",

                        options=\[{"label": "All Regions", "value": "all"}\]

                        + \[{"label": r, "value": r} for r in REGIONS\],

                        value="all",

                        style=dark_select_style,

                    ),

                \],

                width=3,

            ),

            dbc.Col(

                \[

                    dbc.Label("Map Metric", className="text-light"),

                    dbc.Select(

                        id="metric",

                        options=\[

                            {

                                "label": "Absolute Value (years)",

                                "value": "absolute",

                            },

                            {

                                "label": "Change Since Start (%)",

                                "value": "change_start",

                            },

                            {

                                "label": "10-Year Change (%)",

                                "value": "change_10y",

                            },

                            {"label": "Vs. Global Average (%)", "value": "vs_avg"},

                        \],

                        value="absolute",

                        style=dark_select_style,

                    ),

                \],

                width=3,

            ),

        \],

        className="mb-4",

    ),

    \# Charts Row with table

    dbc.Row(

        \[

            \# Map

            dbc.Col(

                \[

                    dbc.Card(

                        \[

                            dbc.CardHeader(

                                "World Map",

                                style={

                                    "backgroundColor": "#3d3d3d",

                                    "color": "#fff",

                                    "fontWeight": "bold",

                                },

                            ),

                            dbc.CardBody(

                                \[dcc.Graph(id="map-chart", style={"height": "500px"})\],

                                style={"padding": "10px"},

                            ),

                        \],

                        style={"backgroundColor": "#2d2d2d", "border": "none"},

                        className="shadow",

                    )

                \],

                width=8,

            ),

            \# Country table

            dbc.Col(

                \[

                    dbc.Card(

                        \[

                            dbc.CardHeader(

                                id="table-title",

                                children="Countries",

                                style={

                                    "backgroundColor": "#3d3d3d",

                                    "color": "#fff",

                                    "fontWeight": "bold",

                                    "padding": "10px 15px",

                                },

                            ),

                            dbc.CardBody(

                                id="table-body",

                                style={"padding": "0px", "maxHeight": "500px", "overflowY": "auto"},

                            ),

                        \],

                        style={"backgroundColor": "#2d2d2d", "border": "none"},

                        className="shadow",

                    )

                \],

                width=4,

            ),

        \],

        className="mb-4",

    ),

    html.Link(

        rel="stylesheet",

        href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css",

    ),

\],

fluid=True,

style={"backgroundColor": "#1e1e1e", "minHeight": "100vh", "padding": "20px"},


)

@app.callback(


\[

    Output("kpi-1", "children"),

    @app.callbackutput("kpi-2", "children"),

    Output("kpi-3", "children"),

    Output("kpi-4", "children"),

    Output("kpi-5", "children"),

    Output("kpi-6", "children"),

\],

\[Input("year-dropdown", "value"), Input("region", "value")\],


)

def update_kpis(year, region):


first_year = years\[0\]



if region == "all":

    dff = df_countries\[\["Country Name", year, first_year\]\].dropna()

else:

    dff = df_countries\[df_countries\["Region"\] == region\]\[

        \["Country Name", year, first_year\]

    \].dropna()



avg_life = dff\[year\].mean()

max_life = dff\[year\].max()

min_life = dff\[year\].min()

median_life = dff\[year\].median()

country_count = len(dff)



avg_first = dff\[first_year\].mean()

global_change = ((avg_life - avg_first) / avg_first) \* 100 if avg_first > 0 else 0

change_text = (

    f"+{global_change:.1f}%" if global_change > 0 else f"{global_change:.1f}%"

)



return (

    create_kpi_card("Average", f"{avg_life:.1f} yrs", "fa-heart-pulse", "#e74c3c"),

    create_kpi_card("Highest", f"{max_life:.1f} yrs", "fa-arrow-up", "#2ecc71"),

    create_kpi_card("Lowest", f"{min_life:.1f} yrs", "fa-arrow-down", "#f39c12"),

    create_kpi_card("Median", f"{median_life:.1f} yrs", "fa-chart-line", "#9b59b6"),

    create_kpi_card("Countries", f"{country_count}", "fa-globe", "#ffffff"),

    create_kpi_card(

        f"Change ({first_year})", change_text, "fa-percent", "#ffffff"
@app.callback    ),

)


@app.callback(

Output("map-chart", "figure"),

\[Input("y@app.callbackar-dropdown", "value"), Input("region", "value"), Input("metric", "value")\],


)

def update_map(year, region, metric):

year_idx = years.index(year)

first_year = years\[0\]

year_10_prior_idx = max(0, year_idx - 10)

year_10_prior = years\[year_10_prior_idx\]



if region == "all":

    dff = df_countries\[\["Country Name", "Country Code", "Region"\] + years\].dropna(

        subset=\[year\]

    )

else:

    dff = df_countries\[df_countries\["Region"\] == region\]\[

        \["Country Name", "Country Code", "Region"\] + years

    \].dropna(subset=\[year\])



global_avg = dff\[year\].mean()



if metric == "absolute":

    dff\["value"\] = dff\[year\]

    color_title = "Years"

    color_suffix = " yrs"

    color_scale = "RdYlGn"

elif metric == "change_start":

    dff = dff.dropna(subset=\[first_year\])

    dff\["value"\] = (dff\[year\] - dff\[first_year\]) / dff\[first_year\] \* 100

    color_title = f"Change since {first_year}"

    color_suffix = "%"

    color_scale = "RdYlGn"

elif metric == "change_10y":

    dff = dff.dropna(subset=\[year_10_prior\])

    dff\["value"\] = (dff\[year\] - dff\[year_10_prior\]) / dff\[year_10_prior\] \* 100

    color_title = "10-Year Change"

    color_suffix = "%"

    color_scale = "RdYlGn"

else:

    dff\["value"\] = (dff\[year\] - global_avg) / global_avg \* 100

    color_title = "Vs. Average"

    color_suffix = "%"

    color_scale = "RdBu"



fig = px.choropleth(

    dff,

    locations="Country Code",

    color="value",

    hover_name="Country Name",

    hover_data={"Country Code": False, "Region": True, "value": ":.1f"},

    color_continuous_scale=color_scale,

    range_color=\[dff\["value"\].min(), dff\["value"\].max()\],

    custom_data=\["Country Name"\],

)



scope_map = {

    "East Asia & Pacific": "asia",

    "Europe & Central Asia": "europe",

    "Latin America & Caribbean": "south america",

    "Middle East & North Africa": "africa",

    "North America": "north america",

    "South Asia": "asia",

    "Sub-Saharan Africa": "africa",

}



if region != "all" and region in scope_map:

    fig.update_geos(scope=scope_map\[region\])

else:

    fig.update_geos(

        showframe=False,

        showcoastlines=True,

        projection_type="natural earth",

        bgcolor="#1e1e1e",

        landcolor="#3d3d3d",

        oceancolor="#1e1e1e",

    )



fig.update_layout(

    paper_bgcolor="#2d2d2d",

    plot_bgcolor="#2d2d2d",

    geo=dict(bgcolor="#2d2d2d"),

    margin=dict(l=0, r=0, t=0, b=0),

    coloraxis_colorbar=dict(

        title=dict(text=color_title, font=dict(color="#fff")),

        ticksuffix=color_suffix,

        bgcolor="#2d2d2d",

        tickfont=dict(color="#fff"),

 @app.callback  ),

)



return fig


@app.callback(


\[

    Output("table-title", "children"),@app.callback
    Output("table-body", "children"),

\],

\[Input("year-dropdown", "value"), Input("region", "value")\],


)

def update_country_table(year, region):

if region == “all”:

dff = df_countries\[\["Country Name", "Country Code", year\]\].dropna(subset=\[year\])

else:

dff = df_countries\[df_countries\["Region"\] == region\]\[

    \["Country Name", "Country Code", year\]

\].dropna(subset=\[year\])

min_val = dff[year].min()

max_val = dff[year].max()

dff = dff.sort_values(year, ascending=False).reset_index(drop=True)

table_rows = []

for idx, row in dff.iterrows():

country_code = row\["Country Code"\]

flag_code = country_code\[:2\].lower() if len(country_code) >= 2 else ""

flag_url = f"https://flagcdn.com/w40/{flag_code}.png"

life_exp = row\[year\]



\# Color gradient from red (low) to green (high)

if max_val > min_val:

    ratio = (life_exp - min_val) / (max_val - min_val)

else:

    ratio = 0.5



\# Interpolate between red (#e74c3c) and green (#2ecc71)

r = int(231 + (46 - 231) \* ratio)

g = int(76 + (204 - 76) \* ratio)

b = int(60 + (113 - 60) \* ratio)

value_color = f"rgb({r}, {g}, {b})"



table_rows.append(

    html.Tr(

        \[

            html.Td(

                f"{idx + 1}",

                style={"padding": "8px 10px", "color": "#888", "width": "35px", "textAlign": "center"}

            ),

            html.Td(

                html.Img(src=flag_url, style={"width": "24px", "borderRadius": "2px"}),

                style={"padding": "8px 5px", "width": "35px"}

            ),

            html.Td(

                row\["Country Name"\],

                style={"padding": "8px 10px", "color": "#fff", "fontWeight": "500"}

            ),

            html.Td(

                f"{life_exp:.1f}",

                style={"padding": "8px 10px", "color": value_color, "fontWeight": "bold", "textAlign": "right"}

            ),

        \],

        style={"borderBottom": "1px solid #3d3d3d"}

    )

)

table = html.Table(

\[

    html.Thead(

        html.Tr(

            \[

                html.Th("#", style={"padding": "10px", "color": "#888", "fontWeight": "normal", "textAlign": "center", "width": "35px"}),

                html.Th("", style={"padding": "10px", "width": "35px"}),

                html.Th("Country", style={"padding": "10px", "color": "#888", "fontWeight": "normal"}),

                html.Th("Years", style={"padding": "10px", "color": "#888", "fontWeight": "normal", "textAlign": "right"}),

            \],

            style={"borderBottom": "2px solid #555", "position": "sticky", "top": "0", "backgroundColor": "#2d2d2d"}

        )

    ),

    html.Tbody(table_rows)

\],

style={"width": "100%", "borderCollapse": "collapse"}

)

title = f"Countries ({len(dff)})" if region == “all” else f"{region} ({len(dff)})"

return title, table

if _name_ == “_main_”:

app.run(debug=True)```


2 Likes

That’s a beautiful app, @Ester . Did Claude find all the country flags?

I also like how you could focus on certain regions of the world with the first dropdown.

In the map metric dropdown, what does the Vs. average option mean?

Thank you, @adamschroeder. I did a lot of editing before it became like this, but there were some surprising things, it added the flags to the table by itself.
The AI ​​didn’t find every photo on the flag_cdn, I’ll look at 1-2 of them again.

Vs. Global Average (%)
:backhand_index_pointing_right: How much a country’s life expectancy is above or below the global average for the selected year, expressed in percent.

1 Like