Figure Friday 2025 - week 44

Hi @Avacsiglo21, one idea is to add the metric (Happiness, Danceability, Energy, Loudness, Acousticness) to the hover information. While the metric would not be obvious at a first glance, it would be shown when interacting/hovering with this visualization.

Ah ok ok make sense thanks you Mike

Awesome app, @marieanne .

Very strong correlation between energy and danceability.

Do you know why there is overlap between the graph and the cards?

@marieanne have you tried deploying an app to the Cloud? I’m curious what you think are the advantages of PyCafe over Cloud?

@Adam thank you for your feedback. The scatterplots are more meant to give you ideas about interesting stuff (meaning more meant to explore) and if you can imagine mapping the dots on a vertical or horizontal line for a characteristic, the year colouring makes it possible to image/see a possible trend over time happening.

Overlap: me being sloppy with not too much time to also look at responsiveness. The second column does not slide under the first. There were some problems with margins and switching to fixed width.

I have 32 “things“ on py.cafe, I want to make sure they work, even after 5 months. Or at least I do not want to think about 2 apps, 7 days etc. I link to py.cafe from my blog. If it was about one app for a customer PC would probably be perfect, this is just about me doing things. And it’s still not very clear if it’s a combination sales PS + PC or if there will be a variant of PC for use cases like mine. I could start a project to merge them all in one app or something like that. BTW my few experiences with PC were fine.

The original code from the vertical navigation post scores 75% on accessibility, mostly not contrast related, I asked Opus 4 to do some optimization on that subject, important. Adjusted code scores 95% on accessibility, 5% left to fix for contrast remarks:

the code
# -*- coding: utf-8 -*-
"""
Created on Thu Nov  6 12:51:45 2025

@author: win11
"""

import dash
from dash import html, Output, Input, dcc, clientside_callback
import dash_bootstrap_components as dbc


app = dash.Dash(
    __name__, 
    external_stylesheets=[dbc.themes.SOLAR],
    # Add meta tags for language and viewport
    meta_tags=[
        {"name": "viewport", "content": "width=device-width, initial-scale=1"},
    ]
)

# Set the language attribute for the HTML document
app.index_string = '''
<!DOCTYPE html>
<html lang="en">
    <head>
        {%metas%}
        <title>{%title%}</title>
        {%favicon%}
        {%css%}
    </head>
    <body>
        {%app_entry%}
        <footer>
            {%config%}
            {%scripts%}
            {%renderer%}
        </footer>
    </body>
</html>
'''

app.title = "My Accessible Dash App"

app.layout = dbc.Container([
    dcc.Location(id="url", refresh='callback-nav'),
    html.Main([  # Use main element for primary content
        html.Section(
            html.H1("Home", className="section-title"),  # Use heading for better structure
            className="section", 
            id="home", 
            **{"data-label": "Home"}
        ),
        html.Section(
            html.H1("About Us", className="section-title"),
            className="section", 
            id="about", 
            **{"data-label": "About Us"}
        ),
        html.Section(
            html.H1("Get In Touch", className="section-title"),
            className="section", 
            id="contact", 
            **{"data-label": "Get In Touch"}
        ),
    ]),
    html.Nav(
        id="my-navigation",
        className="nav",
        **{"role": "navigation", "aria-label": "Main navigation"},
        children=[
            html.Div(
                className="nav-item",
                children=[
                    html.A(
                        "Home",  # Add text content to the link
                        href="#home", 
                        className="nav-link", 
                        id="link1",
                        **{"aria-label": "Navigate to Home section"}
                    ),
                    html.Span("Home", className="nav-label", **{"aria-hidden": "true"})
                ]
            ),
            html.Div(
                className="nav-item",
                children=[
                    html.A(
                        "About Us",  # Add text content to the link
                        href="#about", 
                        className="nav-link", 
                        id="link2",
                        **{"aria-label": "Navigate to About Us section"}
                    ),
                    html.Span("About Us", className="nav-label", **{"aria-hidden": "true"})
                ]
            ),
            html.Div(
                className="nav-item",
                children=[
                    html.A(
                        "Get In Touch",  # Add text content to the link
                        href="#contact", 
                        className="nav-link", 
                        id="link3",
                        **{"aria-label": "Navigate to Get In Touch section"}
                    ),
                    html.Span("Get In Touch", className="nav-label", **{"aria-hidden": "true"})
                ]
            ),
        ])
], fluid=True)

app.clientside_callback(
"""function (id) {
    activateNavigation();
    return window.dash_clientside.no_update
}""", 
Output('my-navigation', 'children'), Input('my-navigation', 'children'))
    
    
if __name__ == '__main__':
    app.run(debug=False)
the js to handle navigation (place somewhere in assets):
function activateNavigation() {
            const sections = document.querySelectorAll(".section");
            const navLinks = document.querySelectorAll(".nav-link");

            const observer = new IntersectionObserver(
                (entries) => {
                    navLinks.forEach((navLink) => {
                        navLink.classList.remove("nav-link-selected");
                    });

                    const visibleSection = entries.find((entry) => entry.isIntersecting);

                    if (visibleSection) {
                        const visibleSectionId = visibleSection.target.getAttribute("id");
                        const correspondingNavLink = document.querySelector(`.nav-link[href="#${visibleSectionId}"]`);
                        if (correspondingNavLink) {
                            correspondingNavLink.classList.add("nav-link-selected");
                        }
                    }
                },
                { threshold: 0.5 }
            );

            sections.forEach((section) => observer.observe(section));
        }
the css (place somewhere in assets):
html {
    scroll-behavior: smooth;
}

body {
    font-family: "Quicksand", sans-serif;
    margin: 0;
    color: #000;
    overflow: hidden;
}

/* uncomment to hide the scrollbar but keep the scroll action*/
/* 
body::-webkit-scrollbar{
    display: none;
} */

.section {
    height: 100vh;
}

#home {
    background: #aff8db;
}

#about {
    background: #ffabab;
}

#contact {
    background: #fff5ba;
}

#section4 {
    background: #2496b3;
}

#section5 {
    background: #002B36;
}

#section6 {
    background: #41eda8;
}

#section7 {
    background: #df3838;
}

#section8 {
    background: #c3f0fb;
}

.nav {
    --nav-gap: 10px;
    padding: var(--nav-gap);
    position: fixed;
    right: 10px;
    top: 50%;
    transform: translateY(-50%);
    display: inline-block;
}

.nav-item {
    align-items: center;
    display: flex;
    margin-bottom: var(--nav-gap);
    flex-direction: row-reverse;
}

.nav-link {
    height: 25px;
    width: 25px;
    background-color: rgba(0, 0, 0, 0.7);
    border-radius: 50%;
    display: inline-block;
    padding: 10px;
    margin: 5px;
    transition: transform 0.1s;
    /* Accessibility additions */
    position: relative;
    overflow: hidden;
    font-size: 0;
}

.nav-link:hover ~ .nav-label {
    opacity: 1;
}

.nav-link-selected {
    background: #000000;
    transform: scale(1.2);
}

.nav-label {
    color: #000;
    font-weight: bold;
    opacity: 0;
    transition: opacity 0.1s;
    font-size: 1rem;
}

/* Visually hide link text but keep it accessible to screen readers */
.nav-link-text {
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

Hi Marianne Really cool, did you run it, if so what about the response timing. I did it some previously but using the client side callback. As you scroll down the page, the visualization on the changes automatically to match the story section you’re reading on the right.

The Scroll Tracker
app.clientside_callback(
“”"
function(n_intervals) {
var scrollTop = window.pageYOffset || document.documentElement.scrollTop;
var windowHeight = window.innerHeight;
var docHeight = document.documentElement.scrollHeight;

    var scrollPercent = scrollTop / (docHeight - windowHeight);
    var section = Math.min(Math.floor(scrollPercent * 9), 8);
    
    return section;
}
"""

Update evere 100 msecond thankt to dcc.Interval and thios setcion updates the visualization
@app.callback(
Output(‘main-viz’, ‘figure’),
Input(‘scroll-position’, ‘data’)
)
def update_viz(section):
This function:

Listens to the section number from the scroll tracker
Shows different charts based on which section you’re in (if/elif statements)
Updates the left-side visualization instantly

The Result: Scroll down → section changes → chart changes. That’s the magic!

Really cool but is it not perfect has delays, locally works but with delays. I also found another parameters inside update_layout called transition that you can adjust to change the image or page, very similar to a PPT.

This week we did a lot of research and code for the scrollytelling :rofl: :rofl: really cool love it

Maybe we get a nice dataset for ff45 to test how fast it really is (or not). :grinning_face: Actually I thought of this when I browsed through the beautiful app Adam shared. Maybe he removed it because it just was too much with the visuals, I was missing navigation.

:rofl: Good Idea :rofl: :rofl:May be or the app stop after 24 hr and forgot to start up again :person_facepalming:

:headphone: Billboard Top Songs Dashboard – Summary

The dashboard is built with Plotly Dash and styled using Bootstrap Darkly, giving it a sleek and modern dark look.
It loads and cleans data from songs.csv using Pandas, converting numeric columns and unifying musical genres.
An Energy Level category (Low, Medium, High) is generated to help group and compare songs easily.
Users can filter results dynamically by Genre and Energy Level, updating all charts and metrics in real time.
The main visualization is a smooth line chart showing Average Energy by Year, revealing energy trends over time.
A correlation heatmap built with the Plasma colorscale highlights relationships between song attributes like BPM, Energy, and Happiness.
Four KPI cards display overall statistics: Avg BPM, Avg Energy, Avg Danceability, and Track Count.
The AG Grid table provides an interactive data view, allowing sorting, filtering, and resizing of columns.
The design combines a dark background with soft yellow accents, offering balance, readability, and elegance.
Overall, it’s a :musical_note: data‑driven, interactive dashboard that helps explore musical trends and correlations across Billboard songs.

https://charmingly-red-wallaby-b6db3bfb.plotly.app

Summary
import plotly.express as px
from dash import Dash, dcc, html, Input, Output
import dash_bootstrap_components as dbc
import dash_ag_grid as dag
import numpy as np

# --- Load dataset ---
df = pd.read_csv("songs.csv")

# --- Clean + numeric ---
numeric_cols = ["BPM", "Energy", "Danceability", "Happiness"]
df[numeric_cols] = df[numeric_cols].apply(pd.to_numeric, errors="coerce")

# --- Clean Genre ---
def clean_genre(value):
    if pd.isna(value):
        return None
    value = str(value).replace("/", ",").replace(";", ",").replace("&", ",")
    parts = [v.strip() for v in value.split(",") if v.strip()]
    return parts[0] if parts else None


df["Discogs Genre"] = df["Discogs Genre"].apply(clean_genre)
df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
df["Year"] = df["Date"].dt.year
df = df.dropna(subset=numeric_cols + ["Song", "Artist", "Discogs Genre"])
df = df[df["Year"].between(1900, 2025)]

# --- Add energy level category ---
def categorize_energy(e):
    if pd.isna(e):
        return "Unknown"
    if e < 40:
        return "Low"
    elif e < 70:
        return "Medium"
    else:
        return "High"

df["Energy Level"] = df["Energy"].apply(categorize_energy)

# --- Compute KPI helper ---
def compute_kpis(data):
    return {
        "avg_bpm": round(data["BPM"].mean(), 1),
        "avg_energy": round(data["Energy"].mean(), 1),
        "avg_dance": round(data["Danceability"].mean(), 1),
        "count_songs": len(data),
    }

# --- Dropdown options ---
genre_opts = ["ALL"] + sorted(df["Discogs Genre"].dropna().unique())
energy_opts = ["ALL", "Low", "Medium", "High"]

# --- Setup App ---
app = Dash(__name__, external_stylesheets=[dbc.themes.DARKLY])
server = app.server

app.title = "🎵 Billboard Dashboard"

# --- Layout ---
app.layout = dbc.Container(
    fluid=True,
    style={
        "background": "black",
        "color": "white",
        "padding": "20px",
        "minHeight": "100vh",
    },
    children=[
        html.H1(
            "🎧 Billboard Top Songs Dashboard",
            style={
                "textAlign": "center",
                "marginBottom": "25px",
                "color": "#F4CD0A",
                "textShadow": "0 0 10px #56D0FF",
            },
        ),
        # --- Filters ---
        dbc.Row(
            [
                dbc.Col(
                    [
                        html.Label("Select Genre:", style={"fontWeight": "bold"}),
                        dbc.Select(
                            id="genre-dropdown",
                            options=[{"label": g, "value": g} for g in genre_opts],
                            value="ALL",
                            style={
                                "backgroundColor": "#111",
                                "color": "white",
                                "borderColor": "#EDF3F5",
                            },
                        ),
                    ],
                    md=4,
                ),
                dbc.Col(
                    [
                        html.Label("Energy Level:", style={"fontWeight": "bold"}),
                        dbc.Select(
                            id="energy-dropdown",
                            options=[{"label": e, "value": e} for e in energy_opts],
                            value="ALL",
                            style={
                                "backgroundColor": "#111",
                                "color": "white",
                                "borderColor": "#FBFBF6",
                            },
                        ),
                    ],
                    md=4,
                ),
            ],
            className="mb-4 align-items-end",
        ),
        # --- KPIs + Charts ---
        dbc.Row(id="kpi-row", className="mb-4"),
        dbc.Row(
            [
                dbc.Col(dcc.Graph(id="energy-year-line"), md=6),
                dbc.Col(dcc.Graph(id="correlation-heatmap"), md=6),
            ],
            className="mb-5",
        ),
        # --- AG GRID at bottom ---
        dbc.Row(
            [
                dbc.Col(
                    html.Div(
                        [
                            html.H4(
                                "🎶 Songs Table",
                                style={
                                    "color": "#F3F1E8",
                                    "textAlign": "left",
                                    "marginBottom": "10px",
                                },
                            ),
                            dag.AgGrid(
                                id="songs-grid",
                                className="ag-theme-alpine-dark",
                                style={"height": "420px", "width": "100%"},
                                columnDefs=[
                                    {"field": "Song", "sortable": True, "filter": True},
                                    {"field": "Artist", "sortable": True, "filter": True},
                                    {"field": "Year", "sortable": True, "filter": True},
                                    {"field": "Discogs Genre", "sortable": True, "filter": True},
                                    {"field": "Energy", "sortable": True},
                                    {"field": "Danceability", "sortable": True},
                                    {"field": "Happiness", "sortable": True},
                                ],
                                rowData=[],
                                defaultColDef={
                                    "resizable": True,
                                    "minWidth": 100,
                                    "cellStyle": {
                                        "backgroundColor": "#101021",
                                        "color": "#FFFFFF",
                                        "border": "1px solid #333",
                                    },
                                },
                            ),
                        ]
                    ),
                    md=12,
                ),
            ]
        ),
    ],
)

# --- Callback ---
@app.callback(
    Output("energy-year-line", "figure"),
    Output("correlation-heatmap", "figure"),
    Output("kpi-row", "children"),
    Output("songs-grid", "rowData"),
    Input("genre-dropdown", "value"),
    Input("energy-dropdown", "value"),
)
def update_dashboard(selected_genre, selected_energy):
    dff = df.copy()
    if selected_genre != "ALL":
        dff = dff[dff["Discogs Genre"] == selected_genre]
    if selected_energy != "ALL":
        dff = dff[dff["Energy Level"] == selected_energy]

    if dff.empty:
        return (
            px.scatter(),
            px.imshow(np.zeros((1, 1))),
            html.Div("No data available.", style={"textAlign": "center"}),
            [],
        )

    # --- KPIs ---
    k = compute_kpis(dff)
    colors = ["#D5CA2E", "#F7F33A", "#E8F408", "#E3E135"]
    labels = ["Avg BPM", "Avg Energy", "Avg Danceability", "Tracks"]
    values = [k["avg_bpm"], k["avg_energy"], k["avg_dance"], k["count_songs"]]

    cards = []
    for lab, val, c in zip(labels, values, colors):
        cards.append(
            dbc.Col(
                dbc.Card(
                    dbc.CardBody(
                        [
                            html.Div(
                                style={
                                    "position": "absolute",
                                    "left": 0,
                                    "top": 0,
                                    "bottom": 0,
                                    "width": "10px",
                                    "backgroundColor": c,
                                    "borderTopLeftRadius": "5px",
                                    "borderBottomLeftRadius": "5px",
                                }
                            ),
                            html.H6(lab, style={"marginLeft": "10px"}),
                            html.H3(
                                val,
                                style={
                                    "marginLeft": "10px",
                                    "color": c,
                                    "fontWeight": "bold",
                                },
                            ),
                        ],
                        style={"position": "relative", "paddingLeft": "20px"},
                    ),
                    style={
                        "backgroundColor": "rgba(0,0,0,0.7)",
                        "border": "1px solid #333",
                    },
                ),
                md=3,
            )
        )
    kpi_cards = dbc.Row(cards, className="mb-4")

    # --- Trend line (Energy per year) ---
    energy_year = (
        dff.groupby("Year", as_index=False)["Energy"].mean().dropna()
    )
    fig1 = px.line(
        energy_year,
        x="Year",
        y="Energy",
        markers=None,
        title="Average Energy by Year",
        template="plotly_dark",
        line_shape="spline",
    )
    fig1.update_traces(line=dict(color="#F2C80F", width=5))
    fig1.update_layout(
        plot_bgcolor="rgba(0,0,0,0.25)",
        paper_bgcolor="rgba(0,0,0,0.3)",
        font_color="white",
        title_x=0.5,
    )

    # --- Correlation heatmap ---
    corr_vars = ["BPM", "Energy", "Danceability", "Happiness"]
    corr = dff[corr_vars].corr().round(2)
    fig2 = px.imshow(
        corr,
        text_auto=True,
        color_continuous_scale="plasma",
        title="Correlation Between Musical Features",
        template="plotly_dark",
        aspect="auto",
    )
    fig2.update_layout(
        plot_bgcolor="rgba(0,0,0,0.25)",
        paper_bgcolor="rgba(0,0,0,0.3)",
        font_color="white",
        title_x=0.5,
    )

    # --- AG Grid data ---
    row_data = (
        dff.sort_values("Year", ascending=False)
        .to_dict("records")
    )

    return fig1, fig2, kpi_cards, row_data


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

@Ester that’s a unique line chart. Is that px.line? I like the thickness and color of the line you chose.

Good job limiting the table to only the data you’re covering in the app.

@adamschroeder Yes, it is px.line. I made it thicker and “spline”.

This project aims to tell a story on The role of vocal technique and instrumentation in hit songs,

The introduction of this project gives you the overall insight into the project Total Hit songs 1,777, Avg Energy Level 62%, Danceability 64%, and other key insight metrics; however, you can get detailed insight from the dataset using the filter feature from the dash Ag grid table.

You can explore historical trends of vocal techniques and instruction in hit songs, filtering the data using a time range slider to aid producers and investors in identifying what works. This analysis helps them understand whether there is a shift in hit music trends and make informed decisions. Other visualizations aim to provide insights for informed decision-making.

image

Click to Explore app

Hi @Moritus
Thank you for submitting your app.

Can you make it public or restart it? I can’t access it.

Good Afternoon, Mr. Adam

ok, acknowledged with thanks