Figure Friday 2025 - week 31

Thank you @adamschroeder and @Avacsiglo21 for your encouragement and tips for deploying on Plotly Cloud. My dashboard for this week is now working there, here is the link: Candy

1 Like

Also, it has been a disappointment to me that my polars-based apps could not be deployed to PyCafe (I am too stubborn to switch back to pandas). So Plotly Cloud is a game changer for me. I would have been happy if it had similar performance as PyCafe, but actually it is much faster, so I recommend using it. No more waiting while the coffee image blows off steam before you can see and use a dashboard. I appreciate the help I received from @nathandrezner

1 Like

Really cool, now I can play around with your dashboard/apps, well done

1 Like

Thanks for joining Plotly Cloud @Mike_Purtell . I look forward to seeing all the apps you plan to create for Figure Friday.

1 Like

This Dash app is an interactive candy comparison dashboard that visualizes sugar, price, and popularity metrics for different candies. The main chart is a bubble chart where each bubble represents a candy, with size, color, and position based on its attributes. Clicking on a bubble triggers a clickData event, (green bubble),which updates the dropdown menu to reflect the selected candy. This selection then updates the donut chart, showing which ingredients are present in that candy. The app also features KPI cards highlighting the most sugary, expensive, and popular candies overall.
(Updated!)

Link2: https://standout-ivory-salmon-c5ccd76b.plotly.app - with Plotly Cloud

Code
from dash import Dash, dcc, html, Input, Output, State
import dash_bootstrap_components as dbc
import plotly.graph_objects as go
import pandas as pd

df = pd.read_csv("candy.csv")

ingredients = [
    "chocolate", "fruity", "caramel", "peanutyalmondy",
    "nougat", "crispedricewafer", "hard"
]
ingredient_labels = [
    "Chocolate", "Fruity", "Caramel", "Peanuty/Almondy",
    "Nougat", "Crisped Rice/Wafer", "Hard"
]

CARD_STYLE = {
    "border": "2px solid white",
    "borderRadius": "18px",
    "boxShadow": "0 4px 16px 0 rgba(0,0,0,0.10)",
    "background": "white",
    "marginBottom": "24px"
}

def kpi_card(title, name, value, color):
    return dbc.Card(
        dbc.CardBody([
            html.H6(
                f"{title}: {name}",
                className="card-title",
                style={"fontWeight": "bold"}
            ),
            html.Div(
                f"{value:.2f} %",
                className=f"text-{color}",
                style={"fontSize": "1.5rem", "fontWeight": "bold"}
            )
        ]),
        className="text-center",
        style={**CARD_STYLE, "padding": "0.5rem", "marginBottom": "0"}
    )

app = Dash(__name__, external_stylesheets=[dbc.themes.FLATLY])
server = app.server

app.layout = dbc.Container([
    dbc.Row([
        dbc.Col(html.H2("🍬 Candy Dashboard"), width=12, className="my-3")
    ]),

    # KPI cards row
    dbc.Row([
        dbc.Col(id="kpi-sugar", md=4, xs=12, className="mb-2"),
        dbc.Col(id="kpi-price", md=4, xs=12, className="mb-2"),
        dbc.Col(id="kpi-win", md=4, xs=12, className="mb-2"),
    ], className="mb-2"),

    # --- Filter row: radio buttons left, dropdown right (stacked on mobile) ---
    dbc.Row([
        # Radio buttons (left on desktop, top on mobile)
        dbc.Col([
            html.Label(
                "X-axis:",
                style={
                    "fontWeight": "bold",
                    "fontSize": "16px",
                    "marginRight": "8px"
                }
            ),
            dcc.RadioItems(
                id="xaxis-radio",
                options=[
                    {"label": "Sugar %", "value": "sugarpercent"},
                    {"label": "Win %", "value": "winpercent"},
                    {"label": "Price %", "value": "pricepercent"},
                ],
                value="sugarpercent",
                labelStyle={
                    "display": "inline-block",
                    "marginRight": "18px",
                    "fontSize": "18px"
                }
            )
        ],
        md=8, xs=12, className="order-md-1 order-1",  # Left on desktop, top on mobile
        style={"marginBottom": "12px"}
        ),

        # Dropdown (right on desktop, bottom on mobile)
        dbc.Col([
            html.Label(
                "Candy:",
                style={
                    "fontWeight": "bold",
                    "fontSize": "16px",
                    "marginRight": "8px"
                }
            ),
            dcc.Dropdown(
                id="candy-dropdown",
                options=[
                    {"label": name, "value": name}
                    for name in sorted(df["competitorname"].unique())
                ],
                value=None,
                clearable=True,
                placeholder="Select a candy...",
                style={"width": "100%", "fontSize": "16px"}
            )
        ],
        md=4, xs=12, className="order-md-2 order-2",  # Right on desktop, bottom on mobile
        style={"marginBottom": "12px"}
        ),
    ], className="mb-3"),

    dbc.Row([
        dbc.Col(
            dbc.Card([
                dbc.CardHeader(
                    "Click a bubble or select from dropdown",
                    style={"background": "white", "borderBottom": "1px solid #eee"}
                ),
                dbc.CardBody([
                    dcc.Graph(
                        id="bubble-chart",
                        style={"height": "490px", "cursor": "pointer"},
                        config={"displayModeBar": False}
                    )
                ])
            ], style={**CARD_STYLE, "height": "600px"}),
            md=6, xs=12
        ),
        dbc.Col(
            dbc.Card([
                dbc.CardHeader(
                    "Ingredients (not in %, only shows which types are present)",
                    style={"background": "white", "borderBottom": "1px solid #eee"}
                ),
                dbc.CardBody([
                    html.Div(
                        id="donut-container",
                        style={"position": "relative", "height": "500px"}
                    )
                ])
            ], style={**CARD_STYLE, "height": "600px"}),
            md=6, xs=12
        )
    ])
], fluid=True, style={
    "minHeight": "100vh",
    "background": "white",
    "padding": "48px"
})

# --- KPI CARDS CALLBACK ---
@app.callback(
    Output("kpi-sugar", "children"),
    Output("kpi-price", "children"),
    Output("kpi-win", "children"),
    Input("candy-dropdown", "value")
)
def update_kpis(_):
    idx_sugar = df["sugarpercent"].idxmax()
    name_sugar = df.loc[idx_sugar, "competitorname"]
    val_sugar = df.loc[idx_sugar, "sugarpercent"]

    idx_price = df["pricepercent"].idxmax()
    name_price = df.loc[idx_price, "competitorname"]
    val_price = df.loc[idx_price, "pricepercent"]

    idx_win = df["winpercent"].idxmax()
    name_win = df.loc[idx_win, "competitorname"]
    val_win = df.loc[idx_win, "winpercent"]

    return (
        kpi_card("Most Sugary", name_sugar, val_sugar, "danger"),
        kpi_card("Most Expensive", name_price, val_price, "info"),
        kpi_card("Most Popular", name_win, val_win, "success"),
    )

# --- Bubble chart: highlight selected candy ---
@app.callback(
    Output("bubble-chart", "figure"),
    Input("xaxis-radio", "value"),
    Input("candy-dropdown", "value"),
)
def update_bubble_chart(xaxis_col, selected_candy):
    filtered_df = df.copy()
    color_vals = filtered_df["winpercent"].fillna(0)
    colorscale = "Sunsetdark"
    marker_colors = color_vals.tolist()
    marker_sizes = filtered_df["pricepercent"].fillna(0) * 30 + 10

    marker_line_colors = ["white"] * len(filtered_df)
    marker_line_widths = [2] * len(filtered_df)
    marker_opacities = [0.85] * len(filtered_df)

    # Highlight if a candy is selected
    if selected_candy is not None and selected_candy in filtered_df["competitorname"].values:
        idx = filtered_df.index[filtered_df["competitorname"] == selected_candy]
        if not idx.empty:
            idx = idx[0] - filtered_df.index[0]
            marker_colors = list(marker_colors)
            marker_colors[idx] = "lightgreen"
            marker_opacities[idx] = 1.0

    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=filtered_df[xaxis_col].fillna(0),
        y=filtered_df["winpercent"].fillna(0),
        mode="markers",
        marker=dict(
            size=marker_sizes,
            color=marker_colors,
            colorscale=colorscale,
            showscale=True,
            colorbar=dict(title="Win %"),
            line=dict(width=marker_line_widths, color=marker_line_colors),
            opacity=marker_opacities,
            sizemode='area',
            sizeref=2.*max(marker_sizes)/(40.**2) if len(marker_sizes) > 0 else 1,
            sizemin=6,
        ),
        text=filtered_df["competitorname"],
        customdata=filtered_df["competitorname"],
        hovertemplate="<b>%{text}</b><br>Sugar %: %{x:.2f}<br>Win %: %{y:.2f}<br>Price %: %{marker.size:.2f}<extra></extra>",
        name="Candies"
    ))

    fig.update_layout(
        xaxis_title={
            "sugarpercent": "Sugar %",
            "winpercent": "Win %",
            "pricepercent": "Price %"
        }[xaxis_col],
        yaxis_title="Win %",
        margin=dict(t=20, l=40, r=20, b=40),
        template="plotly_white",
        dragmode=False,
        hovermode="closest",
        plot_bgcolor="white",
        paper_bgcolor="white"
    )

    return fig

# --- Dropdown and bubble chart sync: clicking a bubble sets the dropdown ---
@app.callback(
    Output("candy-dropdown", "value"),
    Input("bubble-chart", "clickData"),
    State("candy-dropdown", "value"),
    prevent_initial_call=True
)
def sync_dropdown(clickData, current_value):
    if clickData and "points" in clickData and len(clickData["points"]) > 0:
        selected_candy = clickData["points"][0]["customdata"]
        return selected_candy
    return current_value

# --- Donut chart + annotation or icon ---
@app.callback(
    Output("donut-container", "children"),
    Input("candy-dropdown", "value")
)
def update_donut(selected_candy):
    if selected_candy is None or selected_candy not in df["competitorname"].values:
        return html.Div(
            "🍬",
            style={
                "fontSize": "120px",
                "textAlign": "center",
                "lineHeight": "470px",
                "height": "470px"
            }
        )
    clicked_row = df[df["competitorname"] == selected_candy].iloc[0]
    values = []
    labels = []
    orange_gradient = [
        "#FF9800", "#FFA726", "#FFB74D", "#FFCC80",
        "#FFE0B2", "#FFF3E0", "#FFD180"
    ]
    marker_colors = []
    for i, (ing, label) in enumerate(zip(ingredients, ingredient_labels)):
        if clicked_row[ing] == 1:
            values.append(1)
            labels.append(label)
            marker_colors.append(orange_gradient[i % len(orange_gradient)])
    if not values:
        values = [1]
        labels = ["No ingredients"]
        marker_colors = ["#eee"]
        hovertemplate = "<b>%{label}</b><br>No ingredients<extra></extra>"
    else:
        hovertemplate = "<b>%{label}</b><br>Present: Yes<extra></extra>"

    # Break name into lines if too long
    name = selected_candy
    max_line_length = 14
    if len(name) > max_line_length:
        # Try to break at spaces, otherwise just cut
        if " " in name:
            words = name.split(" ")
            lines = []
            current = ""
            for word in words:
                if len(current + " " + word) <= max_line_length:
                    current = (current + " " + word).strip()
                else:
                    lines.append(current)
                    current = word
            if current:
                lines.append(current)
            name_center = "<br>".join(lines)
        else:
            name_center = "<br>".join([name[i:i+max_line_length] for i in range(0, len(name), max_line_length)])
    else:
        name_center = name

    pie_fig = go.Figure(go.Pie(
        labels=labels,
        values=values,
        marker=dict(colors=marker_colors),
        textinfo="label",
        textposition="inside",
        showlegend=False,
        hole=0.45,
        hovertemplate=hovertemplate
    ))
    # Center annotation with candy name
    pie_fig.update_layout(
        margin=dict(t=30, l=30, r=30, b=30),
        template="plotly_white",
        annotations=[
            dict(
                text=f"<b>{name_center}</b>",
                x=0.5, y=0.5, font_size=20, showarrow=False,
                font_color="#FF9800",
                align="center"
            )
        ]
    )
    return dcc.Graph(
        figure=pie_fig,
        style={"height": "470px"},
        config={"displayModeBar": False}
    )

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

Hello Ester,

your apps is private can not be accessed.

Regards

1 Like

Thank you @Avacsiglo21 , I’ve just changed it.

1 Like

What a nice data app, @Ester . Thanks for sharing.
Was this made with Plotly Studio and uploaded to Plotly Cloud, or was this a Dash app that you uploaded to Plotly Cloud?

One thing that I would recommend is moving the dropdown to the right to be above the pie chart and keeping the radio buttons to the left above the scatter plots.

Hello @Ester, great job on your dashboard, and happy to see that you and @Avacsiglo21 and now me are using Plotly Cloud. I really like how fast the apps load, and how fast they perform compared to PyCafe. It’s funny to me that you have a donut chart in an app about candy, as these are 2 of my favorite food groups. I ran your app a few hours ago on my iPhone, and it rendered there very nicely. When I run my own app on the same phone, nothing scales properly, and it looks pretty bad. Is there anything you put in your app that makes it scales properly on big monitors or on small iPhones?

1 Like

Hi @Mike_Purtell!

I used dash bootstrap components and responsive layout:

  • Using dbc.Container(fluid=True, …)
  • Defining columns with md=…, xs=… in dbc.Col
  • Structuring your layout with dbc.Row and dbc.Col

I hope it is useful but I will develop it, it is not very mobil-friendly yet.:slight_smile:

Hi @adamschroeder! I made my Dash app and uploaded to Plotly Cloud, It is not from Plotly Studio.

1 Like

Hi @Mike_Purtell

With dmc you can set different column widths based on screen size with a dictionary in the span prop. I recommend removing all the the offset props and doing something like this for the cards – but feel free to experiment with different numbers if you want it to behave differently:

dmc.Grid(children = [
        dmc.GridCol(dmc_select_attribute,  span={"base": 6,  "lg":3}),
        dmc.GridCol(attribute_card , span={"base": 6,  "lg":3}),
        dmc.GridCol(dmc_select_outcome,  span={"base": 6,  "lg":3}),
        dmc.GridCol(outcome_card , span={"base": 6, "lg":3}),
    ]),

Here’s an example of for the graphs and grid:

dmc.Grid(
        children = [
            dmc.GridCol(dcc.Graph(id='boxplot'),span={"base": 6,  "lg":4} ),
            dmc.GridCol(dcc.Graph(id='histogram'),span={"base": 6, "lg":4}),
            dmc.GridCol(
                [
                    dmc.Text(
                        'Data for each value of {attribute}, {outcome}',
                        style=style_h3,
                        id='table-desc'
                    ),
                    dag.AgGrid(id='ag-grid'),
                ],
                span={"base": 12,  "lg":4}
            ),
        ]
    ),

See more info on this (and other ways to make a responsive layout )in the docs

3 Likes

Hello @AnnMarieW , thank you for these thorough and thoughtful suggestions. I can’t wait to try them out in the next few days.

2 Likes

I had similar issues naming my app for week 31. Looks like these have been fixed, had no problem with naming for week 32.

1 Like