Figure Friday 2025 - week 12

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

Revolutionaries or writers: who ends up appearing more on money?

We will try to answer these and a few other questions by using Plotly and Dash to visualize the Bank Notes data.

Please visit The Pudding’s Readme page for the metadata. And if you’re looking for ideas to analyze the data, The Pudding wrote a nice data story on the topic.

Things to consider:

  • what can you improve in the app or sample figure below (bar chart)?
  • 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-12/banknotesData.csv')

fig = px.bar(df, x='profession', hover_data='hoverText')

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

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


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

Participation Instructions:

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

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

Data Source:

Thank you to The Pudding for the data.

1 Like

Hi community :waving_hand:,

For this Figure Friday I explored the dataset to answer these questions:

1. How diverse is gender representation by country?


image recorted for visibility

2. What professions are most commonly featured and what is their economic weight?

3. How long after death are people typically featured?

:backhand_index_pointing_right: Full analysis in this Google Colab notebook: View Notebook

A few interesting takeaways:

  1. Men are much more represented than women, but some countries like Australia and England feature more women, while Israel and Sweden show a balanced 50/50.
  2. Top professions are Head of Government, Founder, and Writer.
  3. Women tend to be featured sooner after death compared to men.

What other insights do you see in these visualizations? Would love to hear your thoughts!

2 Likes

Interesting grpahs, @Alfredo49 . We missed you :hugs:

Form the box plot, I can see that men also have more outliers (years between death and banknote appearance).
But I’m not sure why women are featured 12 years sooner than men after death (median of 64 vs 76). Why do you think that is happening?

Hello Figure Friday Community:

This week’s Figure Friday project is an interactive dashboard for exploring historical banknote character data. Key features include: interactive visualizations of gender distribution and historical trends, detailed character information via modals, a statistical summary, and dynamic controls like a year range slider and reset button.


Dashboard

The code

import dash
from dash import dcc, html, Input, Output, State
import dash_bootstrap_components as dbc
import pandas as pd
import plotly.express as px
import plotly.graph_objs as go
import numpy as np
from dash.exceptions import PreventUpdate

# Load the data globally
df = pd.read_csv("banknotesData.csv")

# Preprocessing
df['gender'] = df['gender'].map({'F': 'Female', 'M': 'Male'})
df['firstAppearanceDate'] = pd.to_numeric(df['firstAppearanceDate'], errors='coerce')

# Convert numpy types to native Python types
min_year = int(df['firstAppearanceDate'].min())
max_year = int(df['firstAppearanceDate'].max())

# Color palette
color_palette = {
    'Male': '#3498db',   # light blue
    'Female': '#e74c3c',  # Soft red
    'Grid': 'rgba(0,0,0,0.05)',
    'Background': '#f4f6f7', # gray
    'Header': '#34495e'   # Dark blue-gray
}

# Initialize the Dash app
app = dash.Dash(__name__, 
                external_stylesheets=[dbc.themes.FLATLY],
                suppress_callback_exceptions=True,
                meta_tags=[{'name': 'viewport', 'content': 'width=device-width, initial-scale=1'}])
app.title = "Banknote Characters Explorer"

# App Layout
app.layout = dbc.Container([
    # Modal for character details
    dbc.Modal(
        [
            dbc.ModalHeader(
                dbc.ModalTitle("Character Details", className='text-white fw-bold'), 
                close_button=True,
                style={'backgroundColor': color_palette['Header']}
            ),
            dbc.ModalBody(id='character-details-modal-body'),
        ],
        id="character-details-modal",
        size="xl",
        scrollable=True,
        style={'backgroundColor': 'rgba(244, 246, 247,0.95)'}
    ),
    
    # Header
    dbc.Row([
        dbc.Col(html.H1("BillNoteTimeline: Banknote Character Explorer", 
                        className='text-center my-4 text-primary'), 
                width=12)
    ]),
    
    # Main content row
    dbc.Row([
        # Gender Distribution Column
        dbc.Col([
            html.H4("Gender Distribution", className='text-center'),
            html.Hr(style={'backgroundColor': color_palette['Background'], 'height': '10px' }),
            dcc.Graph(id='gender-pie-chart', config={'displayModeBar': False})
        ], width=3),
        
        # Timeline Column
        dbc.Col([
            html.H4("Historical Timeline", className='text-center'),
            dcc.RangeSlider(
                id='year-range-slider',
                min=min_year,
                max=max_year,
                step=20,
                marks={
                    str(min_year): str(min_year),
                    str(max_year): str(max_year),
                    **{str(year): str(year) for year in range(min_year + 20, max_year, 20)}
                },
                value=[min_year, max_year],
                tooltip={'placement': 'bottom', 'always_visible': False}
            ),
            dbc.Button("Reset", id="reset-button", className="mt-2"),
            dcc.Graph(id='timeline-visualization')
        ], width=9)
    ]),
    dbc.Row([
        dbc.Col([
            html.P('🔍 Click on the circle/square to learn more about the characters from that year', 
                   className='text-center text-muted fw-bold'
    )
])
], className='mt-3'),
    
    # Additional Information
    dbc.Row([
        dbc.Col(html.Div(id='stats-summary', className='mt-4 text-center'), width=12)
    ])
], fluid=True, style={'backgroundColor': color_palette['Background']})

# Gender Pie Chart Callback
@app.callback(
    Output('gender-pie-chart', 'figure'),
    [Input('year-range-slider', 'value')]
)
def update_gender_pie_chart(year_range):
    filtered_df = df[
        (df['firstAppearanceDate'] >= year_range[0]) & 
        (df['firstAppearanceDate'] <= year_range[1])
    ]
    
    gender_counts = filtered_df['gender'].value_counts()
    
    fig = px.pie(
        values=gender_counts.values, 
        names=gender_counts.index,
        hole=0.6,
        title=f'Years ({year_range[0]}-{year_range[1]})',
        color_discrete_map={'Male': color_palette['Male'], 'Female': color_palette['Female']},
        template='simple_white'
    )
    fig.update_layout(showlegend=False,paper_bgcolor='rgb(244, 246, 247)', plot_bgcolor='rgb(244, 246, 247)')
    
    fig.update_traces(
        textposition='inside', 
        textinfo='percent+label',
        marker=dict(line=dict(color='#ffffff', width=1.5))
    )
    
    return fig

# Timeline Visualization Callback
@app.callback(
    Output('timeline-visualization', 'figure'),
    [Input('year-range-slider', 'value')]
)
def update_timeline(year_range):
    filtered_df = df[
        (df['firstAppearanceDate'] >= year_range[0]) & 
        (df['firstAppearanceDate'] <= year_range[1])
    ]
    
    year_counts = filtered_df.groupby(['firstAppearanceDate', 'gender']).size().reset_index(name='count')
    
    fig = px.scatter(
        year_counts, 
        x='firstAppearanceDate', 
        y='count', 
        color='gender',
        symbol='gender',
        symbol_sequence=['circle', 'square'],
        size='count',
        template='ygridoff',
        color_discrete_map={'Male': color_palette['Male'], 'Female': color_palette['Female']},
        title=f'Timeline of Characters ({year_range[0]}-{year_range[1]})',
        labels={'firstAppearanceDate': 'Year', 'count': 'Character Count', 'gender': 'Gender'}
    )
    
    fig.update_layout(
        xaxis=dict(title='Year', gridcolor=color_palette['Grid']),
        yaxis=dict(title='Character Count', gridcolor=color_palette['Grid']),
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="center", x=0.5),
        hovermode='closest',
        paper_bgcolor='rgb(244, 246, 247)', plot_bgcolor='rgb(244, 246, 247)'
    )
    
    return fig

# Reset Slider Callback
@app.callback(
    Output('year-range-slider', 'value'),
    Input('reset-button', 'n_clicks'),
    prevent_initial_call=True
)
def reset_slider(n_clicks):
    if n_clicks:
        return [min_year, max_year]
    else:
        raise PreventUpdate

@app.callback(
    [Output('character-details-modal', 'is_open'),
     Output('character-details-modal-body', 'children')],
    [Input('timeline-visualization', 'clickData')],
    [State('year-range-slider', 'value'),
     State('character-details-modal', 'is_open')]
)
def toggle_modal(clickData, year_range, is_open):
    if not clickData:
        return False, []
    
    # Get the clicked year and gender
    year = clickData['points'][0]['x']
    gender = clickData['points'][0]['curveNumber']  # 0 for Male, 1 for Female
    gender_map = {0: 'Male', 1: 'Female'}
    selected_gender = gender_map[gender]
    
    # Filter characters by year range and gender
    year_characters = df[
        (df['firstAppearanceDate'] == year) & 
        (df['gender'] == selected_gender) &
        (df['firstAppearanceDate'] >= year_range[0]) & 
        (df['firstAppearanceDate'] <= year_range[1])
    ]
    
    character_cards = [
        dbc.Card([
            dbc.CardHeader(
                html.H5(char['name'], className='text-white m-0'),
                className='bg-primary text-center'
            ),
            dbc.CardBody([
                html.P(f"🌍 Country: {char['country']}", className='card-text'),
                html.P(f"💼 Profession: {char.get('profession', 'N/A')}", className='card-text'),
                html.P(f"💵 Note: {char.get('currencyName', 'N/A')}", className='card-text'),
                html.P(f"💰 Current Value: {char.get('currentBillValue', 'N/A')}", className='card-text'),
                html.P(f"💬 Comments: {char.get('comments', 'N/A')}", className='card-text')
            ])
        ], className='mb-3 shadow-sm') 
        for _, char in year_characters.iterrows()
    ]
    
    modal_content = [
        html.H4(f"{selected_gender} Characters in {year}", className='text-center mb-4'),
        dbc.Row([dbc.Col(card, width=4) for card in character_cards])
    ]
    
    return not is_open, modal_content

# Stats Summary Callback
@app.callback(
    Output('stats-summary', 'children'),
    [Input('year-range-slider', 'value')]
)
def update_stats_summary(year_range):
    filtered_df = df[
        (df['firstAppearanceDate'] >= year_range[0]) & 
        (df['firstAppearanceDate'] <= year_range[1])
    ]
    
    total_characters = len(filtered_df)
    countries = filtered_df['country'].nunique()
    most_common_country = filtered_df['country'].mode().values[0]
    
    summary = dbc.Card(
        dbc.CardBody([
            html.H5("Dashboard Statistics", className='card-title text-primary'),
            html.P(f"Total Characters: {total_characters}", className='card-text'),
            html.P(f"Countries Represented: {countries}", className='card-text'),
            html.P(f"Most Common Country: {most_common_country}", className='card-text')
        ])
    )
    
    return summary

# Boilerplate for cloud deployment
server = app.server



if __name__ == '__main__':
    app.run_server(debug=True,)

Any comments/suggestion is highly appreciated

Best Regards

3 Likes

That modal :heart_eyes:

1 Like

Here I will show you on the treemap which currency the revolutionary or writer appears on.

Pycafe link will be ready by Friday.

4 Likes

i love the dashboard, especially the Character Details. Nice job, @Avacsiglo21 .
I hope you can join the Friday session to talk about your app :slight_smile:

1 Like

Nice one Eszter. Looking forward to the Pycafe app.

"I’ll do my best to be there, Adam. I missed it last week because I got confused with the time zone. It used to be 2 hours different, but I think it changed to 1. I’ll be sure to pay attention this time.

2 Likes

Hello Ester, I find your treemap chart quite interesting. It would be great/interesting if you would allow me to suggest placing a single filter for the years, and removing the others, leaving the rest as it is. Adding one last piece of information about the comments for each character would make it more granular.

Of course is just a suggestion, I have not seen working

1 Like

Thanks @Avacsiglo21 for the help. You’re right, I’ll do it, I wanted to add something else, but I wasn’t sure what. :slight_smile: I’ll post it on PyCafe tomorrow too.

1 Like

Imagine, my pleasure

Hi everyone!

For this week’s Figure Friday, I built an interactive Dash dashboard, featuring tabs for main analysis, trends, geography, correlation, machine learning, network visualization, and forecasting.
Check it out:

I switched from prophet to statsmodels on py.cafe because Pyodide doesn’t support prophet due to installation issues. The ARIMA model selects the best order via AIC and forecasts 5 years ahead.
On Kaggle, I hit a snag with ERROR: Could not find a version that satisfies the requirement dash_bootstrap_components—any help fixing this would be great!

Would love to hear your thoughts and feedback!

2 Likes

Thanks for submitting this Dash app, @feanor_92 . I love how informative your apps always are.
How long did it take you to build this one?

By the way, I’m hitting the same snag on PyCafe where changing the controls doesn’t update the graphs.

1 Like

I updated PyCafe with the help of @Avacsiglo21 :folded_hands:t3:, I made the slider, but I want still something to improve. :slightly_smiling_face:

1 Like

Ester, it’s a pleasure to contribute to your work or to anyone in the community, that’s what it’s all about.

1 Like

Hi Adam, I was reviewing today’s recorded Figure Friday video, and I wanted to answer why I used the ‘Count’ metric in the size parameter. The reason is simple: many people are visual, and as Matt mentioned about the icons, for me it’s the same effect. The size of the point draws attention faster than the y-axis measure in this case. It might seem redundant, but in this case, it can be useful.

Regards

1 Like

I see. That makes sense, @Avacsiglo21. Thank you.

1 Like

Hey, thanks for your kind words! I actually spent three evenings working on this app.
Do you know if there’s any workaround for this error, or should I just accept it for now?