Case Study 15-1: Priya Builds an Interactive Dashboard for Sandra

Background

It was a Thursday afternoon when Sandra Chen, VP of Sales at Acme Corp, stopped by Priya's desk with a printout. The printout showed two bar charts — regional revenue for Q2 and Q3 — that Priya had emailed as a PNG attachment the previous week.

Sandra placed her finger on a bar in the West region column. "This number," she said. "I can see it's the tallest bar, but I can't read the exact value. The label's too small and the image is blurry when I zoom in."

Priya looked at the printout. The bar chart was crisp at 150 DPI, but Sandra had printed it at full page width on a letter-sized sheet. The font on the y-axis had become a gray smudge.

"What I'd really like," Sandra continued, "is something where I can just — hover over the bar and see the number. Like in a web report."

Priya had heard of plotly but hadn't used it in a business context yet. That evening she read the Chapter 15 section on plotly express and the next morning she started building.


Step 1: Understanding the Audience and the Data

Before writing a line of code, Priya thought through what Sandra actually needed:

  1. Monthly revenue trend against the annual target — Sandra monitors this weekly
  2. Regional breakdown — which of the four regions is performing, which needs attention
  3. Margin vs order size — Sandra wants to see whether large deals are actually profitable
  4. Product mix — how much revenue is coming from Technology vs Office Supplies vs Furniture

Sandra is not a Python user. She uses Excel, email, and Chrome. The output needed to work as a file she could open without any special software.

That ruled out Jupyter notebooks. It pointed directly to a plotly HTML file.

Priya also thought about the data she had available. The analysis data was already in a pandas DataFrame from the Chapter 14 matplotlib work — acme_sales_2023.csv — so the data pipeline was already there. The work was almost entirely in the visualization layer.


Step 2: Choosing plotly Over seaborn

Priya had considered seaborn for this task. Her boxplot and heatmap from Chapter 14 work looked sharp. But she walked through the requirements:

Requirement seaborn plotly
Sandra can hover to see exact values No — static PNG Yes
Sandra can share the file without Python Only if she emails a PNG Yes — HTML works in any browser
Filter by region by clicking the legend No Yes
Sandra can zoom into a specific time range No Yes
Can embed multiple charts in one file No — separate PNGs Yes — subplots in one HTML

plotly was the obvious choice. She'd use seaborn for her own exploratory work and the internal analysis documentation, but the thing Sandra would actually open needed to be interactive.


Step 3: Building the Data Layer

Priya started with the data, not the visualization. This is a habit she'd developed: verify the data is correct before spending time on chart code.

import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Load and prepare the sales data
sales_df = pd.read_csv("acme_sales_2023.csv", parse_dates=["order_date"])

# Monthly aggregate
monthly_summary = (
    sales_df
    .groupby(sales_df["order_date"].dt.to_period("M"))
    .agg(
        actual_revenue=("revenue", "sum"),
        order_count=("order_id", "count"),
        avg_order_value=("revenue", "mean"),
    )
    .reset_index()
)
monthly_summary["month_label"] = monthly_summary["order_date"].dt.strftime("%b %Y")

# Regional aggregate
regional_summary = (
    sales_df
    .groupby("region")
    .agg(
        annual_revenue=("revenue", "sum"),
        order_count=("order_id", "count"),
        avg_deal_size=("revenue", "mean"),
    )
    .reset_index()
)

# Order-level data for scatter (sample 300 for readability)
order_sample = sales_df.sample(300, random_state=42)[
    ["order_id", "revenue", "gross_margin_pct", "category", "rep_name"]
]

Priya checked the data shapes and spot-checked a few values against the Excel source. Everything matched.


Step 4: The Design Decisions

Priya made four deliberate design choices before writing the chart code.

Choice 1: 2×2 layout, not a single scrolling page

She considered arranging the four charts vertically. But a 2×2 grid means everything is visible at once without scrolling. Sandra would be able to present this in a meeting — full screen in Chrome, all four panels visible simultaneously.

Choice 2: Unified hover mode for the trend chart

For the monthly trend chart with both actual and target lines, hovermode="x unified" was essential. Sandra would naturally hover over a month to compare actual vs target. Without unified mode, she'd have to hover precisely over each line separately to get both values.

Choice 3: Custom hover templates for the scatter plot

The default hover on the scatter plot would show x=1823, y=0.31. Useless to Sandra. Priya wrote custom hover templates so hovering over any order dot shows the order ID, the rep name, the order value formatted as dollars, and the margin as a percentage. When Sandra wants to follow up on a low-margin order, she has everything she needs in the tooltip.

Choice 4: Color consistency across panels

The four regions appear in two charts: the regional bar chart and the scatter plot (which has a region filter option in the legend). Priya used the same four colors for the same regions in both. This way, Sandra's eye learns the color mapping once and it applies across the whole dashboard.

REGION_COLORS = {
    "Northeast": "#5C6BC0",   # Indigo
    "Southeast": "#26A69A",   # Teal
    "Midwest": "#EF5350",     # Red
    "West": "#AB47BC",        # Purple
}

Step 5: Building the Subplots

fig = make_subplots(
    rows=2,
    cols=2,
    subplot_titles=[
        "Monthly Revenue vs Target — 2023",
        "Annual Revenue by Region",
        "Gross Margin % vs Order Value",
        "Revenue Share by Product Category",
    ],
    specs=[
        [{"type": "scatter"}, {"type": "bar"}],
        [{"type": "scatter"}, {"type": "pie"}],
    ],
    horizontal_spacing=0.10,
    vertical_spacing=0.14,
)

The specs parameter matters here. The bottom-right panel is a pie chart, and plotly needs to know this upfront because pie charts don't use standard x/y axes. Setting {"type": "pie"} for that cell prevents the layout engine from reserving axis space that won't be used.

Trend panel (top-left):

fig.add_trace(
    go.Scatter(
        x=monthly_summary["month_label"],
        y=monthly_summary["actual_revenue"],
        mode="lines+markers",
        name="Actual Revenue",
        line={"color": "#1565C0", "width": 2.5},
        marker={"size": 7},
        customdata=np.stack(
            [monthly_summary["target_revenue"],
             monthly_summary["vs_target_pct"]],
            axis=1,
        ),
        hovertemplate=(
            "<b>%{x}</b><br>"
            "Actual: $%{y:,.0f}<br>"
            "Target: $%{customdata[0]:,.0f}<br>"
            "vs Target: %{customdata[1]:+.1f}%"
            "<extra></extra>"
        ),
    ),
    row=1, col=1,
)

fig.add_trace(
    go.Scatter(
        x=monthly_summary["month_label"],
        y=monthly_summary["target_revenue"],
        mode="lines",
        name="Target",
        line={"color": "#9E9E9E", "width": 1.8, "dash": "dash"},
        hovertemplate="<b>%{x}</b><br>Target: $%{y:,.0f}<extra></extra>",
    ),
    row=1, col=1,
)

The <extra></extra> at the end of each hovertemplate is a plotly quirk worth noting. Without it, plotly appends the trace name in a small colored box at the bottom of the tooltip. For a clean, professional hover, <extra></extra> suppresses it.

Regional bar (top-right):

fig.add_trace(
    go.Bar(
        x=regional_summary["region"],
        y=regional_summary["annual_revenue"],
        name="Annual Revenue",
        marker_color=[REGION_COLORS[r] for r in regional_summary["region"]],
        text=[f"${v / 1e6:.2f}M" for v in regional_summary["annual_revenue"]],
        textposition="outside",
        customdata=np.stack(
            [regional_summary["order_count"],
             regional_summary["avg_deal_size"]],
            axis=1,
        ),
        hovertemplate=(
            "<b>%{x}</b><br>"
            "Revenue: $%{y:,.0f}<br>"
            "Orders: %{customdata[0]:,}<br>"
            "Avg Deal: $%{customdata[1]:,.0f}"
            "<extra></extra>"
        ),
    ),
    row=1, col=2,
)

Priya added textposition="outside" so the revenue total in millions appears above each bar. This gives Sandra an at-a-glance answer to "how big is each region?" without needing to hover.

Scatter plot (bottom-left):

for region, color in REGION_COLORS.items():
    subset = order_sample[order_sample["region"] == region]
    fig.add_trace(
        go.Scatter(
            x=subset["revenue"],
            y=subset["gross_margin_pct"],
            mode="markers",
            name=region,
            marker={"color": color, "size": 7, "opacity": 0.65},
            customdata=subset[["order_id", "rep_name"]].values,
            hovertemplate=(
                "<b>%{customdata[0]}</b><br>"
                f"Region: {region}<br>"
                "Order: $%{x:,.0f}<br>"
                "Margin: %{y:.1%}<br>"
                "Rep: %{customdata[1]}"
                "<extra></extra>"
            ),
        ),
        row=2, col=1,
    )

Category donut (bottom-right):

fig.add_trace(
    go.Pie(
        labels=category_summary["category"],
        values=category_summary["revenue"],
        marker={
            "colors": ["#42A5F5", "#66BB6A", "#FFA726"],
            "line": {"color": "white", "width": 2},
        },
        hole=0.4,
        textinfo="label+percent",
        hovertemplate=(
            "<b>%{label}</b><br>"
            "Revenue: $%{value:,.0f}<br>"
            "Share: %{percent}"
            "<extra></extra>"
        ),
    ),
    row=2, col=2,
)

Step 6: The Global Layout and the Title

total_revenue = regional_summary["annual_revenue"].sum()

fig.update_layout(
    title={
        "text": (
            f"<b>Acme Corp Executive Dashboard — FY 2023</b><br>"
            f"<sup>Total Revenue: ${total_revenue:,.0f}  |  "
            f"Hover over any chart element for details  |  "
            f"Click legend items to filter</sup>"
        ),
        "x": 0.5,
        "xanchor": "center",
        "font": {"size": 20, "family": "Arial"},
    },
    height=820,
    width=1200,
    plot_bgcolor="white",
    paper_bgcolor="#F5F5F5",
    hovermode="closest",
    font={"family": "Arial", "size": 11},
)

The subtitle text — "Hover over any chart element for details | Click legend items to filter" — was Marcus Webb's idea, actually. When Priya showed him the draft, he immediately asked if it was interactive. She showed him the hovering and the legend filtering. He said, "Sandra won't know to try that. Tell her in the title."

Good advice. Instruction belongs in the interface.


Step 7: Saving and Delivering

fig.write_html(
    "acme_q3_executive_dashboard.html",
    include_plotlyjs=True,    # Fully self-contained — no internet required
    config={
        "displayModeBar": True,
        "responsive": True,
        "toImageButtonOptions": {
            "format": "png",
            "filename": "acme_dashboard_export",
            "scale": 2,
        },
    },
)

Priya set include_plotlyjs=True rather than "cdn" because she wasn't sure whether Sandra or her colleagues would be reviewing the dashboard from a location with reliable internet. The full-embed version was about 3.2 MB — larger than a typical email attachment but well within Acme's email limits.

She emailed the file on Friday morning with the subject line: "Q3 Dashboard (interactive) — open in Chrome or Edge."


What Happened Next

Sandra's reply came within four minutes.

"This is fantastic. I just spent 20 minutes going through it. The West margin scatter is concerning — there are 14 orders from one rep with margins under 15%. Can you pull those out for me? Also — can we get this updated every Monday morning?"

Three things happened in that email:

  1. Sandra had independently discovered an insight from the interactive scatter plot that Priya hadn't planned to highlight. The tooltip showing the rep name made it visible.

  2. Sandra now trusted the data enough to act on it. A static chart would have prompted "is this right?" — the interactivity let Sandra validate the numbers herself.

  3. Sandra asked about automation. That would come in Chapter 22 (scheduling) — but the groundwork was laid.

Priya pulled the low-margin West orders that afternoon. She also made a mental note: the dashboard hadn't just answered Sandra's original questions. It had generated new ones that were more specific and more actionable.

That's what good data visualization does.


Key Decisions Recap

Decision Choice Reason
Library plotly (not seaborn) Sandra needs interactivity and a shareable file
Format HTML (not PNG) No Python required to open; hover works
Layout 2×2 subplots All panels visible simultaneously; no scrolling
Hover templates Custom for each panel Business-relevant labels, not raw column names
Color scheme Consistent across panels Same color = same region throughout
include_plotlyjs True (embedded) Works offline; no CDN dependency
Subtitle instruction Added usage hint Non-technical users don't know to hover

This case study demonstrates: make_subplots, go.Scatter, go.Bar, go.Pie, custom hover templates, consistent color mapping, write_html with embedded JS, and the business design thinking behind dashboard construction.