Figure Friday 2025 - week 44

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

What is the breakdown of Lyrical Topic Trends for top the 100 songs since 1958?

Answer this question and a few others by using Plotly on the Billboard Hot 100 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 is a screenshot of the app created by Plotly Studio on top of this dataset:

Prompt for the bar chart / treemap:

Artist genre distribution as a bar chart with multi-select dropdown for CDR Genre and Discogs Genre (default: top 10 genres)

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 Chris Dalla Riva for the data.

Hey all,
For those interested in seeing the app we built during the Data Viz Together Session, see the YouTube recording of the live session.

3 Likes

Cool session, I could not be there :flexed_biceps:

Hello from FFF Community Week 44,

For this week’s dataset, after exploring it in detail, my first idea was to implement a technique I saw and really
liked called Scrollytelling—an interesting technique used, as I understand, by The Pudding and other sites.
But it’s difficult to implement, or at least for me, because it requires a lot of JavaScript knowledge (which I don’t have).
I still tried to do it in Dash—an interesting challenge—but the results weren’t very good.
I think maybe using the technique @Mike_Purtell used last week with Dash Mantine could work, I’m not sure.
So anyway, I changed strategies and after going around in circles, I built this dashboard to analyze songs that reach #1 on Billboard.

I didn’t want to overcomplicate it, so I decided to use artists as the main filter and make only 2 charts.
I wanted to understand and compare the performance of these great artists without focusing too much on which generation they belonged to.

What’s it about?
Basically, you can compare up to 3 legendary artists (Beatles, Madonna, Elvis, etc.) and see:

  • Their period of dominance: when they had their hits and how many weeks they reigned at #1
  • Their sonic signature: that unique balance of energy, rhythm, volume, and other elements that defined their sound
  • Their distinctive characteristics: whether they wrote their own songs, their dominant genre, and those details that
    make their music unique

The visuals:

  • A “dumbbell” chart showing the artist’s career over time, specifically the weeks they were #1 vs. the years
  • A polar chart to compare the musical characteristics of each artist
  • Cards with key statistics for each one

The special touch:
I added an animated gradient background (Spotify green, black, turquoise, and orange) that gives it a modern and dynamic vibe. Nothing static, all with subtle movement.

The idea was that anyone could explore and understand what made their favorite artists special, without needing to be an expert in music or data. My favorites: Michael Jackson, Whitney Houston, Mariah Carey,Adele, Alicia Keys

Important to mention: this is the first time I’ve used a dumbbell chart—it seemed like the right choice after using scatter plots.
Same goes for the polar bar chart—I wanted to give it that CD or vinyl record feel.

the app:

some images



4 Likes

Here goes the code

The code

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

CSS for the background animation

custom_css = “”"
@keyframes gradientAnimation {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animated-background {
background: linear-gradient(135deg, #1DB954, #191414, #4ECDC4, #FF6B35);
background-size: 400% 400%;
animation: gradientAnimation 15s ease infinite; /* 15s duration, subtle and infinite movement */
}
“”"
app = dash.Dash(name, external_stylesheets=[dbc.themes.MINTY],
suppress_callback_exceptions=True)

app.title=“Billboard Hot 100 Dashboard”

The custom CSS

app.index_string = f"“”

{{%metas%}} {{%title%}} {{%favicon%}} {{%css%}} {custom_css} {{%app_entry%}} {{%config%}} {{%scripts%}} {{%renderer%}} """

Consistent color palette for artists

ARTIST_COLORS = [‘#1DB954’, ‘#FF6B35’, ‘#4ECDC4’] # Spotify Green, Orange, Turquoise

— DATA LOGIC —

def correct_two_digit_year(dt):
if pd.isna(dt): return dt
if dt.year > 2050: return dt - pd.DateOffset(years=100)
return dt

def min_max_normalize(series, min_val=None, max_val=None):
series = pd.to_numeric(series, errors=‘coerce’).dropna()
if series.empty: return pd.Series([0] * len(series.index), index=series.index)
if min_val is None: min_val = series.min()
if max_val is None: max_val = series.max()
if max_val == min_val: return (series * 0) + 50
return ((series - min_val) / (max_val - min_val)) * 100

def safe_mode(series):
series = series.dropna()
if series.empty: return ‘N/A’
mode_result = series.mode()
if mode_result.empty: return ‘N/A’
return mode_result.iloc[0]

Load data

try:
df = pd.read_csv(‘Billboard Hot 100 Number Ones Database - Data.csv’)
df[‘Date’] = pd.to_datetime(df[‘Date’], errors=‘coerce’)
valid_dates = df[‘Date’].dropna()
df.loc[valid_dates.index, ‘Date’] = valid_dates.apply(correct_two_digit_year)
except Exception as e:
print(f"Error loading data: {e}")

METRIC_Y = ‘Weeks at Number One’
AUDIO_PARAMS = [‘Energy’, ‘Danceability’, ‘Happiness’, ‘Acousticness’, ‘Loudness (dB)’]
DISPLAY_PARAMS = [‘Energy’, ‘Danceability’, ‘Happiness’, ‘Acousticness’, ‘Loudness_Norm’]
ALL_REQUIRED_COLS = AUDIO_PARAMS + [‘Song’, ‘Artist’, ‘Year’, METRIC_Y, ‘Overall Rating’, ‘CDR Genre’, ‘Artist is a Songwriter’]

BINARY_FEATURES = {
‘Artist White’: ‘Artist is White’,
‘Artist Black’: ‘Artist is Black’,
‘Producer Male’: ‘Producer is Male’,
‘Artist is a Songwriter’: ‘Artist is a Songwriter’,
‘Vocally Based’: ‘Vocally Based’,
‘Falsetto Vocal’: ‘Includes Falsetto Vocal’,
‘Cowbell’: ‘Includes Cowbell’
}
BINARY_COLS = list(BINARY_FEATURES.keys())

df[‘Year’] = df[‘Date’].dt.year

Include a check for the existence of ‘df’ in case the load fails

if ‘df’ in locals():
df_clean = df.dropna(subset=ALL_REQUIRED_COLS).copy()
df_clean[‘Loudness_Norm’] = min_max_normalize(df_clean[‘Loudness (dB)’])
artist_options = sorted(df_clean[‘Artist’].unique())
dropdown_options = [{‘label’: artist, ‘value’: artist} for artist in artist_options]
else:
# Error handling if df is not loaded
df_clean = pd.DataFrame(columns=ALL_REQUIRED_COLS + [‘Loudness_Norm’, ‘Year’, ‘Date’])
artist_options =
dropdown_options =

— DEFAULT STATE LOGIC —

Define a default selection (must exist in artist_options)

DEFAULT_ARTISTS = [‘The Beatles’, ‘Elvis Presley’, ‘Madonna’]
INITIAL_SELECTION = [artist for artist in DEFAULT_ARTISTS if artist in artist_options]
if not INITIAL_SELECTION and len(artist_options) >= 3:
INITIAL_SELECTION = artist_options[:3]
elif not INITIAL_SELECTION and artist_options:
INITIAL_SELECTION = artist_options[:1]

— APP LAYOUT —

app.layout = html.Div([
# Container with animated gradient background
dbc.Container([

    # 1. Title and Descriptor 
    dbc.Row([
        dbc.Col([
            html.Div([
                html.H1("💿 Billboard Hot 100: Comparative Elite Analysis", 
                       style={'fontWeight': '900', 'fontSize': '2.8rem', 
                              'color': '#1DB954',
                              'textShadow': '2px 2px 4px rgba(0,0,0,0.3)',
                              'marginBottom': '20px'}), # Increased bottom margin
               
            ], style={'textAlign': 'center', 'padding': '30px 0 20px 0'}) # Increased top/bottom padding
        ])
    ], className="dbc-row"),
    
    # 2. Artist Selector 
    dbc.Row([
        # Column 1: Artist Selector (small)
        dbc.Col([
            html.H4("🎵 Artists to Compare (Max 3)", className="card-title", style={'color': '#1DB954','marginBottom': '15px'}),
            
            # Button Group for Selection and Reset
            html.Div([
                dbc.Button("Open Artist Selector", id="open-modal-button", 
                           color="success", className="me-2", 
                           style={'fontWeight': 'bold', 'backgroundColor': '#1DB954', 'border': 'none'}),
                
                # --- RESET/CLEAR BUTTON ---
                dbc.Button("Clear Selection", id="reset-button", 
                           color="secondary", className="mb-3", 
                           style={'fontWeight': 'bold', 'border': 'none', 'backgroundColor': '#6c757d'}),
            ], className="d-flex mb-3"),
            
            html.Div(id='artist-warning-text', style={'color': '#ff6b6b', 'marginBottom': '10px'})

        ], width=12, md=4, className="mb-4"),
        
        # Column 2: Dashboard Descriptive Text
        dbc.Col([
            html.Div([
                html.P([
                    html.Strong("Analyze the Success Formula:", style={'color': '#1DB954', 'fontWeight': 'bold'}),
                    " 🔓 Unlock the Code to Chart Dominance. Every legendary artist has a unique Success Formula. "
                    "This tool moves beyond listening to reveal the Elite Blueprint behind their biggest hits. ",
                    "Compare the intensity of their reign, analyze their unique Sonic Signature (the balance of Energy, Danceability, "
                    "and that critical, competitive Loudness), and discover the precise production traits that consistently defined a number one sound."
                ], style={'fontSize': '1.0rem', 'color': '#333', 'margin': '0'})
            ], style={'paddingLeft': '10px', 'borderLeft': '3px solid #1DB954'})
        ], width=12, md=8, className="mb-4 d-none d-md-block"),
        
        dcc.Store(id='artists-to-compare', data=INITIAL_SELECTION)
    ], className="g-3 mb-4 dbc-row align-items-center", 
    style={'backgroundColor': 'rgba(255, 255, 255, 0.95)', 'padding': '15px', 'borderRadius': '12px', 'boxShadow': '0 4px 6px rgba(0,0,0,0.1)'}),
    
    # MODAL FOR SELECTION
    dbc.Modal(
        [
            dbc.ModalHeader(dbc.ModalTitle("Select Artists to Compare (Max 3)")),
            dbc.ModalBody(
                dcc.Dropdown(
                    id='artist-checklist',
                    options=dropdown_options,
                    value=INITIAL_SELECTION,
                    multi=True,
                    placeholder="Select up to 3 artists...",
                    style={'color': '#333', 'minHeight': '150px', 'fontSize': '1.05rem','zIndex': '9999'},
                    optionHeight=35
                )
            ),
            dbc.ModalFooter(
                dbc.Button("Apply and Close", id="close-modal-button", className="ms-auto", n_clicks=0, 
                          style={'backgroundColor': '#1DB954', 'border': 'none'})
            ),
        ],
        id="modal-artists",
        is_open=False,
        size="lg",
        scrollable=True,
    ),
    # 3. Charts - EQUAL WIDTHS
    dbc.Row([
        dbc.Col([
            dbc.Card([
                dbc.CardHeader(html.H4("📈 Historical Success: Weeks at #1 by Year", className="mb-0 text-dark")),
                dbc.CardBody([
                    dcc.Graph(id='historical-chart', style={'height': '500px'}, config={'displayModeBar': False}),
                ], style={'padding': '15px'})
            ], style={'background': 'white', 'border': 'none', 'marginBottom': '15px', 'borderRadius': '12px', 'boxShadow': '0 4px 6px rgba(0,0,0,0.1)'})
        ], width=12, lg=6),

        dbc.Col([
            dbc.Card([
                dbc.CardHeader(html.H4("⚡ PRODUCTION FORMULA", className="mb-0 text-dark")),
                dbc.CardBody([
                    dcc.Graph(id='production-chart', style={'height': '500px'}, config={'displayModeBar': False}),
                ], style={'padding': '15px'})
            ], style={'background': 'white', 'border': 'none', 'marginBottom': '15px', 'borderRadius': '12px', 'boxShadow': '0 4px 6px rgba(0,0,0,0.1)'}) 
        ], width=12, lg=6),
    ], style={'marginBottom': '20px'}, className="dbc-row"),

    # 4. Summary Cards
    dbc.Row(id='artist-summary-cards', className="g-3 mb-4 dbc-row"),
    
    # 5. FOOTER
    dbc.Row([
        dbc.Col([
            html.Div([
                html.Hr(style={'borderColor': 'rgba(255,255,255,0.3)', 'margin': '20px 0'}),
                html.Div([
                    html.P([
                        "🎵 ",
                        html.Strong("📊 Billboard Hot 100", style={'color': '#1DB954'}),
                        " | Interactive Hit Analysis Dashboard"
                    ], style={'margin': '5px 0', 'color': '#FFFFFF'}),
                    html.P([
                        "Data courtesy of Chris Dalla Riva",
                        "💻 Developed with ",
                        html.Span("Dash & Plotly | by Avacsiglo 21", style={'color': '#1DB954', 'fontWeight': 'bold'}),
                        f" | Š 2025"
                    ], style={'margin': '5px 0', 'fontSize': '0.9rem', 'color': 'rgba(255,255,255,0.7)'}),
                ], style={'textAlign': 'center'})
            ])
        ], width=12)
    ], style={'marginTop': '40px', 'paddingBottom': '20px'})
    
], fluid=True, style={'padding': '15px'})

], className=‘animated-background’, style={
‘minHeight’: ‘100vh’,
‘margin’: ‘0’,
‘padding’: ‘0’
})

— CALLBACK LOGIC) —

@app.callback(
Output(“modal-artists”, “is_open”),
[Input(“open-modal-button”, “n_clicks”), Input(“close-modal-button”, “n_clicks”)],
[State(“modal-artists”, “is_open”)],
)
def toggle_modal(n_open, n_close, is_open):
if n_open or n_close:
return not is_open
return is_open

1. Reset/Clear Button Callback

@app.callback(
[Output(‘artist-checklist’, ‘value’, allow_duplicate=True),
Output(‘artists-to-compare’, ‘data’, allow_duplicate=True),
Output(‘artist-warning-text’, ‘children’, allow_duplicate=True)],
[Input(‘reset-button’, ‘n_clicks’)],
prevent_initial_call=True
)
def reset_selection(n_clicks):
if n_clicks and n_clicks > 0:
# Clear the selection by returning an empty list
return , , “”

raise dash.exceptions.PreventUpdate

2. DropDown Selection Callback

@app.callback(
[Output(‘artist-checklist’, ‘value’, allow_duplicate=True),
Output(‘artists-to-compare’, ‘data’, allow_duplicate=True),
Output(‘artist-warning-text’, ‘children’)],
[Input(‘artist-checklist’, ‘value’)],
prevent_initial_call=True
)
def update_selection_and_store(selected_list):
warning = “”

if selected_list is None:
    raise dash.exceptions.PreventUpdate
    
if len(selected_list) > 3:
    selected_list = selected_list[:3]
    warning = html.Span("⛔ Only a maximum of 3 artists can be selected.", style={'fontWeight': 'bold'})
    
return selected_list, selected_list, warning

@app.callback(
[Output(‘historical-chart’, ‘figure’),
Output(‘production-chart’, ‘figure’),
Output(‘artist-summary-cards’, ‘children’)],
[Input(‘artists-to-compare’, ‘data’)]
)
def update_analysis(selected_artists):

# Check for empty selection or store data
if not selected_artists or len(selected_artists) == 0:
    empty_msg = html.Div("Select 1 to 3 artists to activate the comparative analysis.", 
                         style={'color': '#FFFFFF', 'textAlign': 'center', 'marginTop': '200px', 'fontSize': '1.2rem'})
    
    # Create empty figures
    fig_hist = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333'), margin=dict(l=20, r=20, t=20, b=20))
    fig_prod = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333')) 
    return fig_hist, fig_prod, dbc.Col(empty_msg, width=12)

if df_clean.empty:
    error_msg = html.Div("Error: Could not load data. Check the CSV file.", 
                         style={'color': '#ff6b6b', 'textAlign': 'center', 'marginTop': '200px', 'fontSize': '1.2rem'})
    fig_hist = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333'), margin=dict(l=20, r=20, t=20, b=20))
    fig_prod = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333'))
    return fig_hist, fig_prod, dbc.Col(error_msg, width=12)


artists_to_compare = selected_artists[:3]
df_filtered = df_clean[df_clean['Artist'].isin(artists_to_compare)].copy() 

if df_filtered.empty:
    empty_msg = html.Div(f"The selected artists have no valid hits in the clean dataset.", 
                         style={'color': '#ff6b6b', 'textAlign': 'center', 'marginTop': '200px', 'fontSize': '1.2rem'})
    fig_hist = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333'), margin=dict(l=20, r=20, t=20, b=20))
    fig_prod = go.Figure().update_layout(plot_bgcolor='white', paper_bgcolor='white', font=dict(color='#333'))
    return fig_hist, fig_prod, dbc.Col(empty_msg, width=12)

# Create consistent color mapping for both charts
color_map = {artist: ARTIST_COLORS[i % len(ARTIST_COLORS)] for i, artist in enumerate(artists_to_compare)}

# 1. DUMBBELL CHART HORIZONTAL - Period of Dominance
fig_hist = go.Figure()

# Sort artists by first hit year
artist_first_year = {artist: df_filtered[df_filtered['Artist'] == artist]['Year'].min() 
                     for artist in artists_to_compare}
sorted_artists = sorted(artists_to_compare, key=lambda x: artist_first_year[x])

# Calculate metrics for each artist
artist_data = []
for artist in sorted_artists:
    df_artist = df_filtered[df_filtered['Artist'] == artist]
    
    first_year = df_artist['Year'].min()
    last_year = df_artist['Year'].max()
    total_weeks = df_artist[METRIC_Y].sum()
    total_hits = len(df_artist)
    
    artist_data.append({
        'artist': artist,
        'first_year': first_year,
        'last_year': last_year,
        'total_weeks': total_weeks,
        'total_hits': total_hits,
        'years_active': last_year - first_year + 1
    })

# Create dumbbells (lines connecting start and end years)
for idx, data in enumerate(artist_data):
    artist = data['artist']
    
    # Line connecting first and last year
    fig_hist.add_trace(go.Scatter(
        x=[data['first_year'], data['last_year']],
        y=[artist, artist],
        mode='lines',
        line=dict(color=color_map[artist], width=4),
        showlegend=False,
        hoverinfo='skip'
    ))
    
    # Start point (first hit)
    fig_hist.add_trace(go.Scatter(
        x=[data['first_year']],
        y=[artist],
        mode='markers',
        marker=dict(
            size=12,
            color='white',
            line=dict(color=color_map[artist], width=3)
        ),
        showlegend=False,
        hovertemplate=f'<b>{artist}</b><br>' +
                     f'First Hit: {data["first_year"]:.0f}<br>' +
                     f'Total Hits: {data["total_hits"]}<br>' +
                     '<extra></extra>',
        name=artist
    ))
    
    # End point (last hit) - filled
    fig_hist.add_trace(go.Scatter(
        x=[data['last_year']],
        y=[artist],
        mode='markers',
        marker=dict(
            size=12,
            color=color_map[artist]
        ),
        showlegend=False,
        hovertemplate=f'<b>{artist}</b><br>' +
                     f'Last Hit: {data["last_year"]:.0f}<br>' +
                     f'Total Weeks at #1: {data["total_weeks"]:.0f}<br>' +
                     '<extra></extra>',
        name=artist
    ))
    
    # Add individual hits as small markers along the line
    df_artist = df_filtered[df_filtered['Artist'] == artist]
    
    for _, row in df_artist.iterrows():
        fig_hist.add_trace(go.Scatter(
            x=[row['Year']],
            y=[artist],
            mode='markers',
            marker=dict(
                size=row[METRIC_Y] * 2,  # Size based on weeks at #1
                color=color_map[artist],
                opacity=0.6,
                line=dict(color='white', width=1),
                sizemin=4
            ),
            showlegend=False,
            hovertemplate=f'<b>{row["Song"]}</b><br>' +
                         f'{artist}<br>' +
                         f'Year: {row["Year"]:.0f}<br>' +
                         f'Weeks at #1: {row[METRIC_Y]:.0f}<br>' +
                         f'Rating: {row["Overall Rating"]:.2f}<br>' +
                         '<extra></extra>',
            name=artist
        ))

# Add legend manually
for artist in sorted_artists:
    fig_hist.add_trace(go.Scatter(
        x=[None],
        y=[None],
        mode='markers',
        marker=dict(size=10, color=color_map[artist]),
        name=artist,
        showlegend=True
    ))

# Get year range
all_years = df_filtered['Year'].dropna()
min_year = int(all_years.min())
max_year = int(all_years.max())

fig_hist.update_layout(
    plot_bgcolor='white',
    paper_bgcolor='white',
    font=dict(color='#333'),
    xaxis=dict(
        showgrid=True,
        gridcolor='#e0e0e0',
        title='Year',
        title_font_color='#333',
        tickfont_color='#333',
        range=[min_year - 2, max_year + 2],
        dtick=5,
        tick0=int(min_year/5)*5
    ),
    yaxis=dict(
        showgrid=True,
        gridcolor='#f0f0f0',
        title='',
        tickfont=dict(size=13, color='#333', family='Arial Black'),
        categoryorder='array',
        categoryarray=sorted_artists
    ),
    legend=dict(
        title='Artists',
        orientation="h",
        yanchor="bottom",
        y=-0.2,
        xanchor="center",
        x=0.5
    ),
    margin=dict(l=120, r=20, t=20, b=60),
    height=500,
    hovermode='closest'
)

# 2. Production Chart (Grouped Polar Bar) - WHITE BACKGROUND
df_radar = df_filtered.groupby('Artist')[DISPLAY_PARAMS].mean().reset_index()
categories = ['Energy', 'Danceability', 'Happiness', 'Acousticness', 'Loudness (Scaled)'] 
fig_prod = go.Figure()

num_artists = len(artists_to_compare)
num_categories = len(categories)
angle_per_category = 360 / num_categories
bar_width = (angle_per_category * 0.9) / num_artists 

artist_indices = {artist: i for i, artist in enumerate(artists_to_compare)}

for i, row in df_radar.iterrows():
    artist = row['Artist']
    values = row[DISPLAY_PARAMS].tolist()
    artist_idx = artist_indices[artist]
    
    base_angles = np.linspace(0, 360, num_categories, endpoint=False)
    center_adjustment = (num_artists - 1) * bar_width / 2 
    shifted_angles = base_angles + (artist_idx * bar_width) - center_adjustment

    fig_prod.add_trace(go.Barpolar(
        r=values,
        theta=shifted_angles, 
        name=artist,
        marker_color=color_map[artist],
        opacity=0.8, 
        marker_line_width=2, 
        marker_line_color='white', 
        width=bar_width 
    ))
    
fig_prod.update_layout(
    polar=dict(
        radialaxis=dict(
            visible=True, 
            range=[0, 110], 
            gridcolor='#BDBDBD', 
            tickfont=dict(color='#333'),
        ),
        angularaxis=dict(
            tickvals=np.linspace(0, 360, num_categories, endpoint=False), 
            ticktext=categories, 
            tickfont=dict(size=12, color='#333'), 
            gridcolor='#BDBDBD'
        ),
        hole=0.25,
        bgcolor='white'
    ),
    paper_bgcolor='white', 
    plot_bgcolor='white', 
    font=dict(color='#333'),
    legend=dict(
        orientation="h", 
        yanchor="bottom", 
        y=-0.2, 
        xanchor="center", 
        x=0.5
    ),
    margin=dict(l=40, r=40, t=20, b=60)
)

# 3. Improved Summary Cards
summary_cards = []

for i, artist in enumerate(artists_to_compare):
    df_artist = df_clean[df_clean['Artist'] == artist]
    
    # Basic metrics
    total_hits = len(df_artist)
    avg_weeks = df_artist[METRIC_Y].mean() if total_hits > 0 else 0
    total_weeks = df_artist[METRIC_Y].sum() if total_hits > 0 else 0
    avg_rating = df_artist['Overall Rating'].mean() if total_hits > 0 else 0
    max_weeks = df_artist[METRIC_Y].max() if total_hits > 0 else 0
    
    # Additional information
    is_songwriter_mode = safe_mode(df_artist['Artist is a Songwriter'])
    songwriter_text = "Yes" if is_songwriter_mode == 1 else "No" if is_songwriter_mode == 0 else "N/A" 
    dominant_genre = safe_mode(df_artist['CDR Genre'])
    
    # Years of activity
    first_year = df_artist['Year'].min()
    last_year = df_artist['Year'].max()
    years_active = last_year - first_year + 1 if total_hits > 1 else 1
    
    # Most successful song
    best_song_idx = df_artist[METRIC_Y].idxmax()
    best_song = df_artist.loc[best_song_idx, 'Song'] if total_hits > 0 else "N/A"
    best_song_weeks = df_artist.loc[best_song_idx, METRIC_Y] if total_hits > 0 else 0
    
    # Average audio parameters
    avg_energy = df_artist['Energy'].mean() if total_hits > 0 else 0
    avg_danceability = df_artist['Danceability'].mean() if total_hits > 0 else 0
    avg_loudness = df_artist['Loudness (dB)'].mean() if total_hits > 0 else 0
    
    # Dominant traits
    trait_scores = {}
    for col in BINARY_COLS:
        if col in df_artist.columns and df_artist[col].count() > 0:
            score = df_artist[col].sum() / df_artist[col].count()
            trait_scores[col] = score
    
    sorted_traits = sorted(trait_scores.items(), key=lambda item: item[1], reverse=True)
    
    trait_text = "Neutral Formula"
    top_traits = []
    for top_trait_key, top_trait_score in sorted_traits:
        if top_trait_score >= 0.5:
             top_trait_label = BINARY_FEATURES.get(top_trait_key, top_trait_key)
             top_traits.append(f"• {top_trait_label} ({top_trait_score * 100:.0f}%)")
        if len(top_traits) >= 2:
            break

    if top_traits:
        trait_text = "\n".join(top_traits)
    

    card_content = [
        html.Div([
            html.H4(artist, className="card-title text-center", 
                   style={'color': color_map[artist], 'fontWeight': 'bold', 'marginBottom': '15px'}),
        ]),
        
        # Success Section
        html.Div([
            html.H6("🏆 SUCCESS STATISTICS", style={'color': color_map[artist], 'fontWeight': 'bold', 'borderBottom': f'2px solid {color_map[artist]}', 'paddingBottom': '5px', 'marginBottom': '10px'}),
            html.Div([
                html.Span("Total #1 Hits: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{total_hits}", style={'color': color_map[artist], 'fontSize': '1.1rem', 'fontWeight': 'bold'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Total Weeks at #1: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{total_weeks:.0f}", style={'color': color_map[artist], 'fontSize': '1.1rem', 'fontWeight': 'bold'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Avg. Weeks/#1: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{avg_weeks:.1f}", style={'color': '#333'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Personal Record: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{max_weeks:.0f} weeks", style={'color': '#333'}) 
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Average Rating: ", style={'fontWeight': 'bold'}), 
                html.Span(f"⭐ {avg_rating:.2f}/10", style={'color': '#333'})
            ], style={'marginBottom': '10px'}),
        ]),
        
        # Most Successful Hit Section
        html.Div([
            html.H6("💎 MOST SUCCESSFUL HIT", style={'color': color_map[artist], 'fontWeight': 'bold', 'borderBottom': f'2px solid {color_map[artist]}', 'paddingBottom': '5px', 'marginBottom': '10px'}),
            html.Div([
                html.Span(f'"{best_song}"', style={'fontStyle': 'italic', 'fontWeight': 'bold', 'color': '#333'}),
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span(f"{best_song_weeks:.0f} weeks at #1", style={'color': '#666', 'fontSize': '0.9rem'}) 
            ], style={'marginBottom': '10px'}),
        ]),
        
        # Period of Activity Section
        html.Div([
            html.H6("📅 PERIOD OF DOMINANCE", style={'color': color_map[artist], 'fontWeight': 'bold', 'borderBottom': f'2px solid {color_map[artist]}', 'paddingBottom': '5px', 'marginBottom': '10px'}),
            html.Div([
                html.Span(f"{first_year:.0f} - {last_year:.0f} ", style={'fontWeight': 'bold', 'color': '#333'}),
                html.Span(f"({years_active:.0f} years)", style={'color': '#666', 'fontSize': '0.9rem'}) 
            ], style={'marginBottom': '10px'}),
        ]),
        
        # Audio Characteristics Section
        html.Div([
            html.H6("🎛️ SONIC SIGNATURE", style={'color': color_map[artist], 'fontWeight': 'bold', 'borderBottom': f'2px solid {color_map[artist]}', 'paddingBottom': '5px', 'marginBottom': '10px'}),
            html.Div([
                html.Span("Average Energy: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{avg_energy:.1f}%", style={'color': '#333'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Average Danceability: ", style={'fontWeight': 'bold'}),
                html.Span(f"{avg_danceability:.1f}%", style={'color': '#333'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Average Loudness: ", style={'fontWeight': 'bold'}),
                html.Span(f"{avg_loudness:.1f} dB", style={'color': '#333'})
            ], style={'marginBottom': '10px'}),
        ]),
        
        # Signature Elements Section
        html.Div([
            html.H6("✨ SIGNATURE ELEMENTS", style={'color': color_map[artist], 'fontWeight': 'bold', 'borderBottom': f'2px solid {color_map[artist]}', 'paddingBottom': '5px', 'marginBottom': '10px'}),
            html.Pre(trait_text, style={'fontSize': '0.85rem', 'color': '#333', 'marginBottom': '5px', 'whiteSpace': 'pre-line'}),
            html.Div([
                html.Span("Primary Genre: ", style={'fontWeight': 'bold'}),
                html.Span(f"{dominant_genre}", style={'color': '#333'})
            ], style={'marginBottom': '5px'}),
            html.Div([
                html.Span("Songwriter: ", style={'fontWeight': 'bold'}), 
                html.Span(f"{songwriter_text}", style={'color': '#333'})
            ], style={'marginBottom': '5px'}),
        ]),
    ]
    
    summary_cards.append(
        dbc.Col(
            dbc.Card(card_content, 
                     style={
                         'background': 'rgba(255, 255, 255, 0.98)', 
                         'border': f'3px solid {color_map[artist]}',
                         'borderRadius': '12px',
                         'boxShadow': f'0 6px 12px rgba(0,0,0,0.15), 0 0 20px {color_map[artist]}40',
                         'padding': '20px'
                     },
                     body=True),
            width=12, lg=int(12/len(artists_to_compare)),
            className="mb-3"
        )
    )
    
return fig_hist, fig_prod, summary_cards

server = app.server

1 Like

@Avacsiglo21 I loved reading the summary cards. And nice touch with the polar chart. It works well here for comparison purposes.

What is the Signature Elements exactly? This one is from Madonna. What does it mean when it’s over 100%?

Finally, I really like reading data stories in the format of Scrollytelling, but you’re right. Dash doesn’t have a Core Component dedicated to that storytelling format. I liked @Mike_Purtell 's technique which was more horizontal scrolling, rather than vertical. @AnnMarieW would it make sense to allow vertical carousel or that’s not really common?

2 Likes

I made a dynamic scatterplot to see if there were interesting things which would be worth while to dive in deeper. I would call this an exploratory dashboard focused on some characteristics. Marker colouring is based on the year, marker size is based on the number of songs the average was based on.

And I played a lot of songs from the …… and for that if you click on a marker you get the related songs. Sorry, no sound sample.

I just listened to the 1966 card songs and am now wondering what dance moves were used in 1966. Winchester Cathedral, how did that happen.

Coproduction of Claude Opus something (all those numbers, the not too expensive one) and I did some work myself too. :slight_smile:

Link on py.cafe: PyCafe - Dash - Billboard Hot 100

3 Likes

Hello Adam,

The Signature Elements section identifies the most common and consistent ingredients used in an artist’s chart-topping formula. Madonna’s ‘Producer Is Male’ score of 183% indicates that multiple producers are credited per song. This isn’t unique to Madonna; there are other instances as well. I need to fix this or at least present the data in a different way. I left it like this, knowing you would notice and I could ask about it. Good Catch! :rofl: :rofl: :flexed_biceps:
I agree with you, scrollytelling is a really cool technique. For this dataset, I wanted to tell the Billboard story by focusing on the decades. The thing with the horizontal scrolling should be presented like an animated PowerPoint presentation, I think

2 Likes

Adam,

One question/curiosity there Is a way that these cards can be done with Plotly AI, not complete but with different metric not just one?

1 Like

Yes, I’m pretty sure that Plotly Studio can create this cards if you write up an accurate prompt.

2 Likes

@Avacsiglo21 by the way community member, Waliy, tends to be Dash apps in a unique storytelling fashion.
Here’s his LinkedIn post and here’s the Dash app that he just built.

4 Likes

Fantastic dashboard @Avacsiglo21. I spent much time playing with it because it is interesting and so much fun to use. One minor area for improvement is in the production formula chart. The five attribute labels (Danceability, Energy, Loudness, Acousticness, and Happiness) are easy to map with a band like U2 in this screen shot, where their results are superimposed on one of the 5 radial lines. But it is not so clear to me which attribute is represented for bands that are not aligned with the radial lines. For example, between Happiness and Danceability you can see the values for Dire Straits and Neil Young, but it is not clear if those are Happiness or Danceability metrics. A suggestion is to place the labels between the radial lines instead of coincident with them. Hope this makes sense, great job.

2 Likes

Very nice @marieanne . My oh my, the music from 1966 was so good. Great job on this dashboard. One suggestion though for the scatter plot is add the name of the band to the hover info.

2 Likes

Not sure how a vertical carousel would work, but a good way to do story telling in Dash is using a tour component like dash-yada

If you do want to use a vertical carousel, you can do that with dmc:

4 Likes

Hello Mike Thanks a lot for comments adn suggestion makes sense.

1 Like

I found a post where the endresult does vertical scrolling in another also familiar way, Making Navigation Dots (using intersection observer) . The gif just shows the results of all the code snippets from the post.

The result looks like this (sorry, my first gif, on hovering the dots you should see the section name, on clicking it smoothly scrolls to the section):

Recording 2025-11-06 130646

3 Likes

Ok Really cool thanks Marianne for sharing, is html, css and javascript

1 Like

This is what you suggested, Mike. For me, this way is more difficult to understand which columns represent each characteristic. What do you think? The other way would be to put the labels exactly in the middle of the quadrant, which I honestly don’t know how to do.:winking_face_with_tongue:

1 Like

dash yada demo is super cool, but is an assistant robot

1 Like

Ok :sweat_smile: , no remark could point out more that I should change the general name of the scatterplot. All dots are a combination of the 2 average values of the songs of that year for the selected characteristics. Worst case scenario approx 20 bandnames in the hover?

2 Likes