Figure Friday 2025 - week 28

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

How has the perception of country-level corruption changed over time?

Answer this question and a few others by using Plotly and Dash on the corruption perception index dataset. There are two datasets; feel free to choose the one that you prefer to work with.

Things to consider:

  • what can you improve in the app or sample figure below (choropleth map)?
  • 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-28/CPI2024.csv")

fig = px.choropleth(df, color="Rank", locations="ISO3", hover_name="Country / Territory",
                    title="Ranking of Corruption Perceptions Index 2024")
fig.update_layout(margin={"r":0,"t":30,"l":0,"b":10})


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=False)

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

3 Likes

This was a quick run I had at exploring CPI 2024 source divergence using Dash and Plotly. I built a simple app to visualize how different institutional sources rate countries in terms of perceived corruption.

Each source (e.g., Bertelsmann, PRS, EIU) applies different criteria. Using Z-score normalization, the app highlights how some sources score countries significantly higher or lower than the global average — revealing patterns of divergence that may indicate institutional bias or focus.

:magnifying_glass_tilted_right: Key features:

  • Z-score heatmap across 13 CPI sources and 10 major countries
  • Plain-language guidance for interpreting divergence
  • Inline insights explaining where and why certain scores differ

More analysis coming later this week using the new Plotly Studio app, where I’m experimenting with how easy it is to spin up apps/charts on demand!

import pandas as pd
import plotly.express as px
import dash
from dash import Dash, dcc, html, Input, Output
import numpy as np

# Load full CPI 2024 data
cpi_2024 = pd.read_csv("C:/Users/tdent/Downloads/CPI2024.csv")

# Relevant source columns
source_columns = [
    'African Development Bank CPIA',
    'Bertelsmann Foundation Sustainable Governance Index',
    'Bertelsmann Foundation Transformation Index',
    'Economist Intelligence Unit Country Ratings',
    'Freedom House Nations In Transit',
    'S&P / Global Insights Country Risk Ratings',
    'IMD World Competitiveness Yearbook',
    'PERC Asia Risk Guide',
    'PRS International Country Risk Guide',
    'Varieties of Democracy Project',
    'World Bank CPIA',
    'World Economic Forum EOS',
    'World Justice Project Rule of Law Index'
]

# Create z-score normalized data for a selected set of countries
bias_df = cpi_2024[['Country / Territory', 'ISO3'] + source_columns].copy()
bias_df = bias_df.dropna(subset=source_columns, how='all')
z_scores = bias_df.copy()
for col in source_columns:
    col_mean = bias_df[col].mean()
    col_std = bias_df[col].std()
    z_scores[col] = (bias_df[col] - col_mean) / col_std

# Focused countries to show possible bias
focus_countries = ['Russia', 'China', 'United States', 'Germany', 'France', 'Iran', 'Ukraine', 'Venezuela', 'Brazil', 'India']
z_scores_filtered = z_scores[z_scores['Country / Territory'].isin(focus_countries)].set_index('Country / Territory')
z_scores_filtered = z_scores_filtered[source_columns].T.reset_index().rename(columns={'index': 'Source'})

# Build Dash app
app = Dash(__name__)

app.layout = html.Div([
    html.H1("CPI Source Score Divergence (Z-Scores)", style={"textAlign": "center"}),
    html.P("This chart reveals how different institutional sources rate a selection of countries in 2024, relative to their global scoring behavior. Z-scores show whether a source scores a country significantly higher or lower than average."),
    html.P("How to read this chart:", style={"fontWeight": "bold"}),
    html.Ul([
        html.Li("Z-score > 0: The source scored the country higher than the global average (perceived cleaner)."),
        html.Li("Z-score < 0: The source scored the country lower than average (perceived more corrupt)."),
        html.Li("Z-scores closer to ±2 suggest notable divergence from consensus, potentially indicating bias."),
    ]),
    html.Div([
        html.P("\n🔍 Insight: The Bertelsmann Foundation Transformation Index evaluates the state of political and economic transformation based on expert analysis, often weighing democratic norms and market reforms. \nIn the case of Venezuela, it scored significantly below the global average, likely due to democratic erosion, political repression, hyperinflation, and restricted civil liberties. \nThis divergence is consistent with Bertelsmann’s strong emphasis on pluralism and institutional accountability.", style={"fontStyle": "italic", "marginTop": "1rem"}),

        html.P("\n🔍 Insight: The Economist Intelligence Unit (EIU) Country Ratings reflect expert assessments of political stability, rule of law, and civil liberties. Countries like Germany and the US score significantly above average, aligning with the EIU’s liberal-democratic lens. Meanwhile, Russia and Iran receive substantially lower scores, which is consistent with EIU's historical focus on institutional independence and electoral legitimacy.", style={"fontStyle": "italic", "marginTop": "1rem"}),

        html.P("\n🔍 Insight: The PRS International Country Risk Guide measures political, economic, and financial risk. Its relatively higher scores for China and lower scores for Ukraine or Venezuela may reflect a stronger emphasis on macroeconomic and security-related risks, which can differ from democratic governance indicators.", style={"fontStyle": "italic", "marginTop": "1rem"}),

        html.P("\n🔍 Insight: The World Justice Project (WJP) Rule of Law Index emphasizes constraints on government power, absence of corruption, and protection of fundamental rights. Countries like India or Brazil sometimes receive lower-than-average scores, suggesting gaps in enforcement and civil rights protections even in otherwise functioning democracies.", style={"fontStyle": "italic", "marginTop": "1rem"})
    ]),
    dcc.Graph(id='bias_heatmap')
])

@app.callback(
    Output('bias_heatmap', 'figure'),
    Input('bias_heatmap', 'id')
)
def update_heatmap(_):
    melted = z_scores_filtered.melt(id_vars='Source', var_name='Country', value_name='Z-Score')
    fig = px.imshow(
        z_scores_filtered.set_index('Source').T,
        labels=dict(x="Country", y="Source", color="Z-Score"),
        color_continuous_scale="RdBu",
        zmin=-2.5,
        zmax=2.5,
        aspect="auto",
        title="Source Deviation from Average (Z-Score)"
    )
    fig.update_layout(height=600, margin=dict(l=40, r=40, t=60, b=40))
    return fig

if __name__ == '__main__':
5 Likes

:tada: Happy 1-Year Anniversary to Plotly’s #FigureFriday!

Today it happened. My search for “Figure Friday week 28” returned links for 2024 and 2025. It has been 1 full year, second year is underway.

A huge shoutout and thanks to Adam Schroeder, to plotly, and to all the data lovers, designers, analytics enthusiasts and FFFs (Figure Friday Friends) for coming together every Friday to explore the art and power of data visualization. This weekly meetup has showcased stunning figures while sparking creativity, learning, and community.

Whether you came for the charts or stayed for the insights, it’s been a year of turning numbers into narratives. Here’s to the brilliant minds, the jaw-dropping graphs, and the conversations that keep us growing.

:bar_chart: Cheers to many more Fridays of inspiring figures and shared knowledge!

8 Likes

Hi @ThomasD21M. Your approach using z_scores to flag possible corruption is reasonable to me and easy to understand. If I am reading this right, a high z_scores (>+2) are just as suspect as low z_scores (<-2).

Adding the 4 insights above your chart with notes on how to interpret helps quite a bit. Great job.

1 Like

Very well said, Mike. I’ve only been actively involved for six months, and for me, it’s been a watershed. It’s helped me focus on what I’m truly passionate about, and the most important thing is meeting incredible people from a wide variety of backgrounds, always willing to give you respectful feedback. Excellent work by Adam and Plotly, without a doubt.

5 Likes

Thank you for the celebratory and beautiful words, @Mike_Purtell and @Avacsiglo21. I’m honored to be part of such a dedicated, talented, and kind community. You all make these figure Fridays not only a great place to learn and improve our data viz skils but also a place to share and grow together. I always look forward to our Friday sessions where we get see each other again and catch up :slight_smile:

4 Likes

What an interesting way to tackle and visualize the dataset, @ThomasD21M . Sweet.

Can you share a little more about the z-score interpretation? Does 1 z-score mean 1 standard deviation from the average? Thus, for example, the US is viewed by the S&P/Global Insights as one standard deviation ‘cleaner’ (less corrupt) than average, while the same country is viewed by PERC/Asia Risk Guide as 1/2 a standard deviation more corrupt than average?
That’s a big divergence.

2 Likes

Happy Anniversary Figure Friday Friends and Plotly Dash! :tada:I started here a few months ago and I’ve learned a lot of new things both professionally and technically. Thanks to @adamschroeder and everyone here.:folded_hands:t3:

3 Likes

Update: minor code cleanup and bug fixes. I will describe them in a separate post

This dashboard shows all countries on a choropleth map. Hover data shows the region name, country name and worldwide corruption rank for 2024. Scatter plot to the right of the choropleth shows worldwide corruption rank by year, for all countries in the selected region. Selected country’s trace is blue with lines and markers, other countries on this plot are gray with no markers. Graph objects was used for easier customization.

The pulldown menu on the top right selects one country from a list of all countries in the selected region. A double callback dynamically generates the list of selectable countries from the selected region.

The bottom of the dashboard is a dash-ag table with the historical data by year for the selected country.

This screenshot for region EUROPE CENTRAL ASIA is focused on TURKEY. The steady increase in corruption from 2014 mirrors the tenure of President Erdogan who started the same year, 2014

This screen shot is a scatter plot for Ukraine. Corruption has been decreasing since 2014, the same year that Russia invaded its Crimea region. President Zelenskyy has been in power since 2019, so the trend of reduced corruption started well in advance of his tenure.

Here is the code.

import polars as pl
import plotly.express as px
import plotly.graph_objects as go
import dash
import dash_ag_grid as dag
from dash import Dash, dcc, html, Input, Output
import dash_mantine_components as dmc
dash._dash_renderer._set_react_version('18.2.0')

# map region abbreviations to full names, add to datasets with left join
df_region = (
    pl.DataFrame({
        'REGION_ABBR' : ['AME', 'AP', 'ECA', 'MENA', 'SSA','WE/EU'],
        'REGION' : [
            'AMERICAS','ASIA PACIFIC', 'EUROPE CENTRAL ASIA',
            'MIDDLE EAST, NORTH AFRICA','SUB-SAHARAN AFRICA','WESTERN EUROPE, '
            'EUROPEAN UNION'
        ]
    })
)

df_cpi = (
    pl.scan_csv('CPI2024.csv')
    .select(          
        COUNTRY = (
            pl.col('Country / Territory')
            # fix naming inconsistency for both Koreas
            .replace('Korea, North', 'north korea')
            .replace('Korea, South', 'south korea')
            .str.to_uppercase()
        ),
        ISO3 = pl.col('ISO3'), # no mods to ISO3 column
        REGION_ABBR = pl.col('Region'),
        RANK_2024= pl.col('Rank'),
    )
    .collect()  # convert to polars dataframe - lazy frames can't join
    .join(      # add region full names to this dataset
        df_region,
        on='REGION_ABBR'
    )
    .sort(['REGION', 'RANK_2024'])
)

df_historical = (
    pl.scan_csv('CPI-historical.csv')
    .select(
        COUNTRY = (
            pl.col('Country / Territory')
            .str.to_uppercase()
            .replace('UNITED STATES OF AMERICA', 'UNITED STATES')
        ),
        ISO3 = pl.col('ISO3'), # no mods to ISO3 column
        YEAR = pl.col('Year').cast(pl.String).str.slice(2,2),
        REGION_ABBR = pl.col('Region'),
        RANK= pl.col('Rank'),
    )
    # exclude countries not present in both datasets
    .filter(~pl.col('COUNTRY').is_in(['Brunei Darussalam', 'Puerto Rico']))
    .collect() # convert to polars dataframe - lazy frames can't join
    .join(     # add region full names to this dataset
        df_region,
        on='REGION_ABBR'
    )
)

#----- GLOBALS -----------------------------------------------------------------
style_horiz_line = {'border': 'none', 'height': '4px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 32}

style_plain_line = {'border': 'none', 'height': '2px', 
    'background': 'linear-gradient(to right, #d3d3d3, #d3d3d3)', 
    'margin': '10px,', 'fontsize': 16}

style_h2 = {'text-align': 'center', 'font-size': '32px', 
            'fontFamily': 'Arial','font-weight': 'bold'}
bg_color = 'lightgray'
legend_font_size = 20
px.defaults.color_continuous_scale = px.colors.sequential.YlGnBu
region_list = sorted(
    df_historical.unique('REGION')
    .select(pl.col('REGION'))
    ['REGION']
    .to_list()
)
country_list = sorted(
    df_historical.unique('COUNTRY')
    .select(pl.col('COUNTRY'))
    ['COUNTRY']
    .to_list()
)

#----- FUNCTIONS ---------------------------------------------------------------
def get_choropleth():
    choropleth = px.choropleth(
        df_cpi.sort('RANK_2024'), 
        color='RANK_2024',
        locations="ISO3", 
        hover_name='COUNTRY',
        title='country level corruption rank 2024'.upper(),
        custom_data=['REGION', 'REGION_ABBR', 'COUNTRY', 'RANK_2024'],
    )
    choropleth.update_layout(
        margin={"r":0,"t":30,"l":0,"b":10}, 
        showlegend=True,
        hoverlabel=dict(
                bgcolor="white",
                font_size=16,
                font_family='arial',
            ),
        legend=dict(
            orientation='h',
            yanchor='bottom',
            y=1.02,
            xanchor='center',
            x=0.5,
            font=dict(family='Arial', size=legend_font_size, color='black'),
            title=dict(
                text='<b>COUNTRY</b>',
                font=dict(family='Arial', size=legend_font_size, color='black'),
            )
        )
    )
    choropleth.layout.coloraxis.colorbar.title = 'RANK'
    choropleth.update_traces(
        hovertemplate =
            '<b>%{customdata[0]} (%{customdata[1]})</b><br>' + # Reg name,abbr
            '%{customdata[2]}<br>' + 
            'Worldwide Rank: %{customdata[3]}' + 
            '<extra></extra>',
    )
    return choropleth

def get_scatter(region, focus_country):
    country_list = (
        df_historical
        .filter(pl.col('REGION') == region)
        .unique('COUNTRY')
        ['COUNTRY']
        .to_list()
    )
    df_plot =(
        df_historical
        .filter(pl.col('COUNTRY').is_in(country_list))
        .pivot(
            on='COUNTRY',
            index='YEAR',
            values='RANK'
        )
        .sort('YEAR')
    )
    fig = go.Figure()
    for i, country in enumerate(country_list, start=1):
        scatter_mode='lines'
        trace_color ='#D3D3D3'  # lightgray
        line_width=1
        z=0  # for z order for unfocused countries
        if focus_country == country:
            trace_color='#0000FF'      #  blue
            line_width=2                 # thicker line
            scatter_mode='lines+markers'
            z=1                    # for zorder, to place focus_country on top
        x = df_plot['YEAR']
        y = df_plot[country]
        hover_text = [
            f"{country}<br>YEAR: 20{x_val}<br>WW RANK: {y_val}" 
            for x_val, y_val in zip(x, y)
        ]
        fig.add_trace(
            go.Scatter(
                x=x,
                y=y,
                mode=scatter_mode,
                name=f'Trace {i+1}',
                line=dict(color=trace_color),
                marker=dict(color=trace_color),
                hoverinfo='text',
                hovertext=hover_text,
                zorder=z
            )
        )
    fig.update_traces(showlegend=False)
    fig.update_layout(
        title=(
            f'REGION: {region}<br>' +
            '<sup>Worldwide Rank for <span style="color: blue">' +
            f'<b>{focus_country}</b></span></sup>'
        ),
        yaxis_title='Worldwide Rank',
        xaxis_title='YEAR (20XX)',
        template='simple_white'
    )
    return fig


#----- DASH COMPONENTS ---------------------------------------------------------
grid = dag.AgGrid(
    rowData=[],
    columnDefs=[{
        "field": i,
        'filter': True, 
        'sortable': True
        } for i in df_historical.columns
    ],
    dashGridOptions={"pagination": True},
    columnSize="sizeToFit",
    id='ag-grid'
)
dmc_select_region = dmc.RadioGroup(
    children= dmc.Group([dmc.Radio(i, value=i) for i in region_list], my=10),
    value=region_list[0],
    label= 'Select a Region',
    size='sm',
    id='select-region'
)

dmc_select_country = dmc.Select(
    label='Select a country',
    data=country_list,
    value='',
    checkIconPosition='left',
    size='sm',
    id='select-country',
)



#----- DASH APPLICATION STRUCTURE-----------------------------------------------
app = Dash()
app.layout =  dmc.MantineProvider([
    dmc.Space(h=30),
    html.Hr(style=style_horiz_line),
    dmc.Text('Country Level Corruption'.upper(), ta='center', style=style_h2),
    dmc.Space(h=20),
    html.Hr(style=style_horiz_line),
    dmc.Space(h=20),
    dmc.Grid(  
        children = [ 
            dmc.GridCol(dmc_select_region, span=7, offset=1),
            dmc.GridCol(dmc_select_country, span=3, offset=0)
        ]
    ),
    html.Hr(style=style_plain_line),
    dmc.Space(h=20),
    dmc.Grid(
        children = [ 
            dmc.GridCol(
                dcc.Graph(figure=get_choropleth()),
                id='choropleth', 
                span=6, 
                offset=1
            ),
            dmc.GridCol(dcc.Graph(id='scatter'),span=5, offset=0),
        ]
    ),
    dmc.Grid(
        children = [
            dmc.GridCol(grid, span=8, offset=1),
        ]
    ),
])

#----- CALLBACKS----------------------------------------------------------
@app.callback(
    Output('select-country', 'data'),  # pulldown menu choices
    Output('select-country', 'value'), # pulldown menu default value 
    Input('select-region', 'value'),
)
def update_country_list(region):
    callback_country_list = sorted(
        df_cpi
        .filter(pl.col('REGION') == region)
        .select(pl.col('COUNTRY'))
        ['COUNTRY']
        .to_list()
    )
    default_country = callback_country_list[0]
    return (
        callback_country_list,  # pulldown menu choices
        default_country         # pulldown menu default value 
    )

@app.callback(
    Output('ag-grid', 'rowData'), 
    Output('scatter', 'figure'),  
    Input('select-region', 'value'),
    Input('select-country', 'value'),
)
def update_dashboard(region, country):
    df_ag_grid = (
        df_historical
        .filter(pl.col('COUNTRY') == country)
        .sort('YEAR')
    )
    row_data = df_ag_grid.to_dicts()
    go_scatter = get_scatter(region, country)
    return (
        row_data, go_scatter
    )

#----- RUN THE APP -------------------------------------------------------------
if __name__ == "__main__":
    app.run(debug=True)
5 Likes

What better way of celebrating :partying_face: 1 year Figure Friday with @adamschroeder than submitting a viz.
I’m participating since the end of last year, it was a “goed voornemen” and it has brought me a lot.

Sometimes you have to start somewhere again, my submission.
Three things to say about it:

  • what looked like a good idea, was not really a good idea. Click on a bar, show a grid for the selected region and cpi score range with the countries. This should make it easier to see at a glance potential alternatives for business if you want to go abroad. Uruguay gets kudos.
  • I used the score and not the rank. If everybody has a score of 40, they are all ranked 1 but the overall idea is a perception of corruption etc.
  • almost AI free. I needed to remember a lot.

That’s it, that’s my submission (with bugs on py.cafe), had to give one of the columns a fixed height because the plot was growing and growing vertically eternally. That makes it difficult to click on a bar, it resembles a game of pacman more. :sweat_smile:

Thank you Adam and all the others for making this such a fun and educational experience!

Edit one day later: I followed Adam’s advice, the grid with header now appears as a modal and I think it’s an improvement.

Py.cafe:

7 Likes

Love it! :rocket:

2 Likes

Hi Mike, I really like this combination of Map and Line Graph to show the timeline of corruption in countries. I can suggest, instead of showing all the corruption timelines for countries, showing only the selected one and plotting lines of average maximum and minimum corruption by continent or globally. Just a suggestion, it’s very good.

2 Likes

Very nice dashboard @marieanne, I really enjoyed playing with it. I like how the histogram hoverdata is used for making the table with the sparklines. Your artistic style is outstanding. I like that I can play with this on PyCafe, and jealous because PyCafe does not support my dataframe library of choice (polars is multi-threaded). I am too stubborn to revert back to pandas. :slight_smile: Could not agree more that there is no better way to celebrate 1 year of Figure Friday than by submitting a dashboard or visualization.

1 Like

@marieanne , what a great idea to incorporate those line charts inside Dash AG Grid :fire:
And the markers indicating the highs and lows are a nice touch.

One thing that could improve the width limitation of the page is to put the AG Grid table in a modal that pops up when a bar marker is chosen.

2 Likes

There were inconsistencies with country names in the 2 provided datasets for North and South Korea, and United States. Also, Puerto Rico and Brunei Darussalam are in the file CPI_historical.csv, but not in CPI2024.csv. If your dashboards can select specific countries, I recommend testing them with these nations. My update a few minutes ago takes care of these issues.

Thank you @Avacsiglo21. Your suggestions are very good but for now I won’t be able to implement them because I have a very busy week ahead. I submitted my dashboard much earlier than I usually do (often at the last minute) for this reason.

1 Like

The CPI Time Series Dashboard 2012-2024 is an interactive interface for visualizing global and regional Corruption Perception Index (CPI) data. Its central feature is a world map displaying data for a year selectable by a slider, with the chosen year also annotated directly on the map. The “Trend” tab features a line chart showing the temporal evolution of CPI scores for selected countries or regions, with distinct markers for minimum and maximum values. On the “Ranking” tab, users can browse a table of Top 10, Bottom 10, or all countries’ rankings corresponding to the year set on the slider. Users can further filter the view by selecting regions and one or more countries for a comprehensive, customized analysis.
I used dbc components and a liittle css.



I’m still working on the layout and interactivity :slight_smile:

Link to app on render

Code
import plotly.express as px
import plotly.graph_objects as go
import dash
from dash import dcc, html, Input, Output
import dash_bootstrap_components as dbc

# Adatok betöltése
df = pd.read_csv("CPI-historical.csv")
all_years = sorted(df["Year"].unique())
all_regions = sorted(df["Region"].dropna().unique())
all_countries = sorted(df["Country / Territory"].dropna().unique())
latest_year = max(all_years)
min_year = min(all_years)

region_names = {
    "WE/EU": "Western Europe / European Union",
    "AP": "Asia Pacific",
    "AME": "Americas",
    "SSA": "Sub-Saharan Africa",
    "MENA": "Middle East & North Africa",
    "ECA": "Eastern Europe & Central Asia",
}

color_scales = {
    "Plasma": px.colors.sequential.Plasma,
    "Viridis": px.colors.sequential.Viridis,
    "Cividis": px.colors.sequential.Cividis,
    "Turbo": px.colors.sequential.Turbo,
    "Magma": px.colors.sequential.Magma,
}


def kpi_box(label, value, color="#fff"):
    return html.Div(
        [
            html.Div(
                label,
                style={
                    "fontSize": "1.05rem",
                    "color": "#aaa",
                    "marginBottom": "0.2rem",
                },
            ),
            html.Div(
                str(value),
                style={
                    "fontSize": "1.35rem",
                    "color": color,
                    "fontWeight": "bold",
                },
            ),
        ],
        style={
            "backgroundColor": "#222",
            "borderRadius": "12px",
            "padding": "1.1rem",
            "margin": "0.3rem",
            "boxSizing": "border-box",
            "flex": "1 1 200px",
            "minWidth": "200px",
            "boxShadow": "0 2px 8px #0002",
            "textAlign": "center",
        },
    )


def aggregate_kpi_panel(dff, view_name):
    num_countries = dff["Country / Territory"].nunique()
    kpis = [
        kpi_box("View", view_name, color="#0af"),
        kpi_box("Countries", num_countries, color="#0ff"),
    ]
    return html.Div(
        kpis,
        style={
            "display": "flex",
            "flexWrap": "wrap",
            "alignItems": "stretch",
            "margin": "0.5rem 0 1.2rem 0",
            "width": "100%",
        },
    )


def single_country_kpi_panel(country, year, df):
    row = df[(df["Country / Territory"] == country) & (df["Year"] == year)]
    if row.empty:
        return html.Div("No data for this selection.")

    row_data = row.iloc[0]
    region = row_data["Region"]
    region_label = region_names.get(region, region)

    world_df = df[df["Year"] == year].sort_values(
        "CPI score", ascending=False
    )
    world_rank = (
        world_df.reset_index(drop=True)
        .reset_index()
        .set_index("Country / Territory")
        .loc[country, "index"]
        + 1
    )
    world_total = world_df.shape[0]

    region_df = world_df[world_df["Region"] == region]
    region_rank = (
        region_df.reset_index(drop=True)
        .reset_index()
        .set_index("Country / Territory")
        .loc[country, "index"]
        + 1
    )
    region_total = region_df.shape[0]

    kpis = [
        kpi_box("Country", country),
        kpi_box("Region", region_label, color="#0af"),
        kpi_box("CPI score", row_data["CPI score"], color="#0ff"),
        kpi_box("World rank", f"{world_rank} / {world_total}"),
        kpi_box("Region rank", f"{region_rank} / {region_total}"),
    ]
    return html.Div(
        kpis,
        style={
            "display": "flex",
            "flexWrap": "wrap",
            "alignItems": "stretch",
            "margin": "0.5rem 0 1.2rem 0",
            "width": "100%",
        },
    )


def color_scale_legend(scale_name):
    colors = color_scales[scale_name]
    n = len(colors)
    return html.Div(
        [
            html.Div(
                "Color scale:",
                style={
                    "color": "#fff",
                    "fontSize": "0.9rem",
                    "marginBottom": "0.2rem",
                },
            ),
            html.Div(
                [
                    html.Div(
                        style={
                            "display": "inline-block",
                            "width": f"{100/n}%",
                            "height": "18px",
                            "backgroundColor": color,
                        }
                    )
                    for color in colors
                ],
                style={
                    "width": "100%",
                    "display": "flex",
                    "borderRadius": "4px",
                    "overflow": "hidden",
                    "boxShadow": "0 0 2px #333",
                },
            ),
            html.Div(
                [
                    html.Span(
                        "Low",
                        style={
                            "color": "#fff",
                            "fontSize": "0.8rem",
                            "float": "left",
                        },
                    ),
                    html.Span(
                        "High",
                        style={
                            "color": "#fff",
                            "fontSize": "0.8rem",
                            "float": "right",
                        },
                    ),
                ],
                style={
                    "width": "100%",
                    "marginTop": "2px",
                    "clear": "both",
                },
            ),
        ],
        style={"width": "50%", "margin": "0 auto 1rem auto"},
    )


def get_line_color(selected_scale):
    scale = color_scales[selected_scale]
    idx = int(len(scale) * 0.7) if len(scale) > 4 else len(scale) // 2
    return [scale[idx]]


app = dash.Dash(
    __name__,
    external_stylesheets=[dbc.themes.DARKLY],
    meta_tags=[
        {"name": "viewport", "content": "width=device-width, initial-scale=1.0"}
    ],
)
server = app.server


app.layout = html.Div(
    [
        dbc.Container(
            [
                dbc.Row(
                    [
                        dbc.Col(
                            html.H1(
                                "CPI Time Series Dashboard 2012-2024",
                                className="text-center text-light mb-4",
                            ),
                            width=12,
                        )
                    ]
                ),
                dbc.Row(
                    [
                        dbc.Col(
                            html.Div(id="kpi-panel", style={"width": "100%"}),
                            width=12,
                        )
                    ],
                    className="mb-2",
                ),
                dbc.Row(
                    [
                        dbc.Col(
                            [
                                dbc.Label("Region", className="text-light"),
                                dbc.Select(
                                    id="region-select",
                                    options=[
                                        {"label": "All regions", "value": "all"}
                                    ]
                                    + [
                                        {
                                            "label": region_names.get(r, r),
                                            "value": r,
                                        }
                                        for r in all_regions
                                    ],
                                    value="all",
                                    className="bg-dark text-light",
                                ),
                            ],
                            lg=4,
                            md=6,
                            xs=12,
                            className="mb-2",
                        ),
                        dbc.Col(
                            [
                                dbc.Label("Country", className="text-light"),
                                dcc.Dropdown(
                                    id="country-select",
                                    options=[
                                        {"label": c, "value": c}
                                        for c in all_countries
                                    ],
                                    value=[],
                                    multi=True,
                                ),
                            ],
                            lg=4,
                            md=6,
                            xs=12,
                            className="mb-2",
                        ),
                        dbc.Col(
                            [
                                dbc.Label(
                                    "Map color scale", className="text-light"
                                ),
                                dbc.Select(
                                    id="color-scale-select",
                                    options=[
                                        {"label": k, "value": k}
                                        for k in color_scales.keys()
                                    ],
                                    value="Plasma",
                                    className="bg-dark text-light",
                                ),
                            ],
                            lg=4,
                            md=12,
                            xs=12,
                            className="mb-2",
                        ),
                    ],
                    className="mb-3",
                ),
                dbc.Row(
                    [
                        dbc.Col(
                            [
                                dbc.Label("Select Year", className="text-light"),
                                dcc.Slider(
                                    id="year-slider",
                                    min=min_year,
                                    max=latest_year,
                                    value=latest_year,
                                    marks={
                                        str(year): str(year)
                                        for year in all_years
                                        if year % 5 == 0
                                    },
                                    step=1,
                                ),
                            ],
                            width=12,
                        )
                    ],
                    className="mb-4",
                ),
                dbc.Row(
                    [
                        dbc.Col(
                            [
                                html.Div(id="color-legend-div"),
                                dcc.Graph(
                                    id="map-chart",
                                    config={"displayModeBar": False},
                                    style={"height": "550px"},
                                ),
                            ],
                            width=12,
                        )
                    ],
                    className="mb-4",
                ),
                dbc.Row(
                    [
                        dbc.Col(
                            [
                                dbc.Tabs(
                                    [
                                        dbc.Tab(
                                            label="Trend",
                                            children=[
                                                dcc.Graph(
                                                    id="line-chart",
                                                    config={
                                                        "displayModeBar": False
                                                    },
                                                    style={"height": "550px"},
                                                )
                                            ],
                                            tab_id="tab-trend",
                                            label_style={"color": "#0cf"},
                                        ),
                                        # MÓDOSÍTÁS: Ranking fül tartalma
                                        dbc.Tab(
                                            label="Ranking",
                                            children=[
                                                dbc.RadioItems(
                                                    id="ranking-mode-select",
                                                    options=[
                                                        {
                                                            "label": "Top 10",
                                                            "value": "Top 10",
                                                        },
                                                        {
                                                            "label": "Bottom 10",
                                                            "value": "Bottom 10",
                                                        },
                                                        {
                                                            "label": "All",
                                                            "value": "All",
                                                        },
                                                    ],
                                                    value="Top 10",
                                                    inline=True,
                                                    className="dbc d-flex justify-content-center my-3",
                                                    inputClassName="btn-check",
                                                    labelClassName="btn btn-outline-info",
                                                ),
                                                html.Div(
                                                    id="ranking-grid-container",
                                                    style={
                                                        "height": "480px",
                                                        "overflowY": "auto",
                                                    },
                                                ),
                                            ],
                                            tab_id="tab-ranking",
                                            label_style={"color": "#0cf"},
                                        ),
                                    ]
                                )
                            ],
                            width=12,
                        )
                    ],
                ),
            ],
            fluid=True,
            style={"backgroundColor": "#000", "padding": "20px"},
        )
    ],
    style={"backgroundColor": "#000"},
)


@app.callback(
    Output("country-select", "options"),
    Output("country-select", "value"),
    Input("region-select", "value"),
    Input("country-select", "value"),
)
def update_country_options(selected_region, current_countries):
    if selected_region == "all":
        options = [{"label": c, "value": c} for c in all_countries]
        value = current_countries
    else:
        region_countries = sorted(
            df[df["Region"] == selected_region]["Country / Territory"]
            .dropna()
            .unique()
        )
        options = [{"label": c, "value": c} for c in region_countries]
        value = [c for c in current_countries if c in region_countries]
    return options, value


@app.callback(
    Output("map-chart", "figure"),
    Output("line-chart", "figure"),
    Output("color-legend-div", "children"),
    Output("kpi-panel", "children"),
    Output("ranking-grid-container", "children"),
    Input("country-select", "value"),
    Input("region-select", "value"),
    Input("color-scale-select", "value"),
    Input("ranking-mode-select", "value"),
    Input("year-slider", "value"),
)
def update_dashboard(
    selected_countries,
    selected_region,
    selected_scale,
    ranking_mode,
    selected_year,
):
    dff_full_year = df[df["Year"] == selected_year]

    # --- Ranking grid (táblázat) generálása ---
    if selected_region == "all":
        dff_ranking_context = dff_full_year
        ranking_context_name = "World"
    else:
        dff_ranking_context = dff_full_year[
            dff_full_year["Region"] == selected_region
        ]
        ranking_context_name = region_names.get(selected_region)

    ranked_df = dff_ranking_context.copy()
    ranked_df["Rank"] = (
        ranked_df["CPI score"]
        .rank(method="min", ascending=False)
        .astype(int)
    )

    if ranking_mode == "Top 10":
        ranking_data = ranked_df.sort_values(
            "CPI score", ascending=False
        ).head(10)
        title_text = (
            f"Top 10 Countries in {ranking_context_name} ({selected_year})"
        )
    elif ranking_mode == "Bottom 10":
        ranking_data = ranked_df.sort_values("CPI score", ascending=True).head(
            10
        )
        title_text = (
            f"Bottom 10 Countries in {ranking_context_name} ({selected_year})"
        )
    else:  # "All" opció
        ranking_data = ranked_df.sort_values("CPI score", ascending=False)
        title_text = (
            f"All Countries in {ranking_context_name} ({selected_year})"
        )

    display_df = ranking_data[["Rank", "Country / Territory", "CPI score"]]
    ranking_grid = dbc.Table.from_dataframe(
        display_df,
        striped=True,
        bordered=True,
        hover=True,
        color="dark",
        responsive=True,
    )
    ranking_output = html.Div(
        [html.H5(title_text, className="text-center text-light mt-2"), ranking_grid]
    )

    # --- Térkép, vonaldiagram és KPI logika ---
    line_fig = px.line(
        title="Select country/countries to see trend"
    ).update_layout(
        template="plotly_dark",
        plot_bgcolor="#111",
        paper_bgcolor="#111",
    )

    if len(selected_countries) > 1:
        dff_map = dff_full_year[
            dff_full_year["Country / Territory"].isin(selected_countries)
        ]
        map_title = "CPI: Multiple Countries Selected"
        dff_line = df[df["Country / Territory"].isin(selected_countries)]
        line_fig = px.line(
            dff_line,
            x="Year",
            y="CPI score",
            color="Country / Territory",
            title="CPI Score Comparison",
            markers=False,
            line_shape="spline",
        )
        kpi_panel = aggregate_kpi_panel(dff_map, "Custom Selection")

        for country in selected_countries:
            country_df = dff_line[dff_line["Country / Territory"] == country]
            if not country_df.empty:
                min_row = country_df.loc[country_df["CPI score"].idxmin()]
                max_row = country_df.loc[country_df["CPI score"].idxmax()]
                line_fig.add_trace(
                    go.Scatter(
                        x=[min_row["Year"]],
                        y=[min_row["CPI score"]],
                        mode="markers",
                        marker=dict(color="red", size=14, symbol="circle"),
                        hoverinfo="skip",
                        showlegend=False,
                    )
                )
                line_fig.add_trace(
                    go.Scatter(
                        x=[max_row["Year"]],
                        y=[max_row["CPI score"]],
                        mode="markers",
                        marker=dict(
                            color="lightgreen", size=14, symbol="circle"
                        ),
                        hoverinfo="skip",
                        showlegend=False,
                    )
                )

    elif len(selected_countries) == 1:
        country = selected_countries[0]
        dff_map = dff_full_year[dff_full_year["Country / Territory"] == country]
        map_title = f"CPI: {country}"
        dff_line = df[df["Country / Territory"] == country]
        line_color = get_line_color(selected_scale)
        line_fig = px.line(
            dff_line,
            x="Year",
            y="CPI score",
            title=f"CPI Score Over Time: {country}",
            markers=False,
            line_shape="spline",
            color_discrete_sequence=line_color,
        )
        kpi_panel = single_country_kpi_panel(country, selected_year, df)

        if not dff_line.empty:
            min_score_row = dff_line.loc[dff_line["CPI score"].idxmin()]
            max_score_row = dff_line.loc[dff_line["CPI score"].idxmax()]
            line_fig.add_trace(
                go.Scatter(
                    x=[min_score_row["Year"]],
                    y=[min_score_row["CPI score"]],
                    mode="markers+text",
                    marker=dict(color="red", size=16, symbol="circle"),
                    text=["Min"],
                    textposition="bottom center",
                    showlegend=False,
                )
            )
            line_fig.add_trace(
                go.Scatter(
                    x=[max_score_row["Year"]],
                    y=[max_score_row["CPI score"]],
                    mode="markers+text",
                    marker=dict(
                        color="lightgreen", size=16, symbol="circle"
                    ),
                    text=["Max"],
                    textposition="top center",
                    showlegend=False,
                )
            )

    else:
        if selected_region == "all":
            dff_map = dff_full_year
            map_title = "CPI: World"
            dff_line = (
                df.groupby("Year").agg({"CPI score": "mean"}).reset_index()
            )
            line_title = "CPI Score Over Time: World Average"
            kpi_panel = aggregate_kpi_panel(dff_map, "World")
        else:
            dff_map = dff_full_year[dff_full_year["Region"] == selected_region]
            map_title = f"CPI: {region_names.get(selected_region)}"
            dff_line = (
                df[df["Region"] == selected_region]
                .groupby("Year")
                .agg({"CPI score": "mean"})
                .reset_index()
            )
            line_title = f"CPI Score: {region_names.get(selected_region)} (average)"
            kpi_panel = aggregate_kpi_panel(
                dff_map, region_names.get(selected_region)
            )

        line_color = get_line_color(selected_scale)
        line_fig = px.line(
            dff_line,
            x="Year",
            y="CPI score",
            title=line_title,
            markers=False,
            line_shape="spline",
            color_discrete_sequence=line_color,
        )

        if not dff_line.empty:
            min_score_row = dff_line.loc[dff_line["CPI score"].idxmin()]
            max_score_row = dff_line.loc[dff_line["CPI score"].idxmax()]
            line_fig.add_trace(
                go.Scatter(
                    x=[min_score_row["Year"]],
                    y=[min_score_row["CPI score"]],
                    mode="markers+text",
                    marker=dict(color="red", size=16, symbol="circle"),
                    text=["Min"],
                    textposition="bottom center",
                    showlegend=False,
                )
            )
            line_fig.add_trace(
                go.Scatter(
                    x=[max_score_row["Year"]],
                    y=[max_score_row["CPI score"]],
                    mode="markers+text",
                    marker=dict(
                        color="lightgreen", size=16, symbol="circle"
                    ),
                    text=["Max"],
                    textposition="top center",
                    showlegend=False,
                )
            )

    map_fig = px.choropleth(
        dff_map,
        locations="ISO3",
        color="CPI score",
        hover_name="Country / Territory",
        color_continuous_scale=color_scales[selected_scale],
        range_color=(df["CPI score"].min(), df["CPI score"].max()),
        title=map_title,
    )
    map_fig.update_geos(
        showcoastlines=False,
        showland=True,
        fitbounds="locations",
        showcountries=False,
        showframe=False,
    )
    map_fig.update_layout(
        template="plotly_dark",
        plot_bgcolor="#000",
        paper_bgcolor="#000",
        font_color="#fff",
        margin=dict(l=10, r=10, t=40, b=10),
        coloraxis_showscale=False,
    )

    map_fig.add_annotation(
        x=0.05,
        y=0.1,
        text=str(selected_year),
        showarrow=False,
        font=dict(size=50, color="rgba(255, 255, 255, 0.4)"),
        xref="paper",
        yref="paper",
    )

    line_fig.update_traces(line=dict(width=2))
    line_fig.update_layout(
        template="plotly_dark",
        plot_bgcolor="#111",
        paper_bgcolor="#111",
        font_color="#fff",
        margin=dict(l=10, r=10, t=40, b=10),
    )

    legend = color_scale_legend(selected_scale)

    return map_fig, line_fig, legend, kpi_panel, ranking_output


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

One day, @Mike_Purtell , they will make it possible to use your favourite Polars too. And I’ll be happy to play with your dashboard since I was very curious about the interaction. I liked clicking on the bars in my viz too, I don’t know why exactly :grinning_face:. The idea came actually from being easily able to compare countries across the world. A more advanced version would be some kind of multiselect on the bars with on/off, you click two regions in a bin and both would be shown in the grid. Maybe next time.

1 Like

Thank you for your feedback @adamschroeder, I think you’re right, it would be an improvement, working on it now (almost). I modified the app and it is an improvement, thank you!

2 Likes

Hi everyone!

I’m excited to share my Corruption Perception Index Dashboard. It combines both the 2024 CPI data and a full historical archive to help you explore how global perceptions of corruption have changed over time.

Key features:

  • Overview & Static Charts: Histogram of CPI scores, CPI vs. rank scatter colored by region, and a static choropleth map of 2024.
  • PCA Projection: Two‑component embedding of all numeric indicators, colored by corruption quartile.
  • Historical Trends:
    • Line chart of the global average CPI over the years.
    • Text summary highlighting average change, top 5 improvers, and top 5 decliners since the earliest available data.
  • Animated Choropleth: Year‑by‑year map animation with slider controls to see each country’s CPI evolve.
  • Dark‑themed Dash app built with Bootstrap for a seamless, modern look.

:backhand_index_pointing_right: Try it live on py.cafe:

:backhand_index_pointing_right: Browse the code on GitHub:

I’d love to hear your thoughts, ideas for additional features, or any feedback on improving the app. Enjoy exploring! :rocket:

4 Likes