Case Study 2: Election Night Live — Building an Interactive Results Tracker
Tier 3 — Illustrative/Composite Example: This case study follows Anika, a journalism student, as she builds an interactive election results tracker for her student newspaper's website. All election data, candidate names, county names, vote counts, and demographic figures are entirely fictional, set in the fictional state of "Westfield" with fictional counties. The dashboard architecture, plotly techniques, and design decisions are representative of real election night data visualization workflows used by major news organizations.
The Setting
Anika is the data editor of her university newspaper, The Campus Herald. The state of Westfield is holding its gubernatorial election, and Anika has been asked to build an interactive results page for the newspaper's website. The requirements:
- A choropleth map of Westfield's 42 counties, colored by which candidate is leading.
- A live-updating bar chart showing the overall vote tally.
- A scatter plot showing the relationship between county demographics and voting patterns.
- Everything must work as an HTML file embedded in the newspaper's website.
Anika has three weeks before election day. She will build the dashboard with sample data first, then swap in real data on election night.
The Data Model
Anika designs her data structure before writing any visualization code:
import pandas as pd
# Sample data: 42 counties
results = pd.DataFrame({
"county": ["Adams", "Baker", "Clark", ...],
"fips": ["WF001", "WF002", "WF003", ...],
"candidate_a_votes": [12450, 8920, 31200, ...],
"candidate_b_votes": [11800, 9340, 28700, ...],
"total_votes": [24250, 18260, 59900, ...],
"pct_reporting": [87, 92, 76, ...],
"population": [45000, 32000, 95000, ...],
"median_income": [52000, 48000, 67000, ...],
"college_pct": [34.2, 28.1, 45.7, ...],
"urban_pct": [62, 35, 78, ...]
})
# Derived columns
results["margin"] = (
(results["candidate_a_votes"] -
results["candidate_b_votes"]) /
results["total_votes"] * 100
)
results["leader"] = results["margin"].apply(
lambda m: "Rivera" if m > 0 else "Chen")
The margin column captures the percentage-point difference: positive means Candidate A (Rivera) leads, negative means Candidate B (Chen) leads. This single column drives the choropleth coloring.
Chart 1: The County Map
Since Westfield is fictional, Anika cannot use plotly's built-in US county geometries. Instead, she uses a scatter plot on a simple coordinate system representing county centroids:
import plotly.express as px
fig_map = px.scatter(
results, x="longitude", y="latitude",
color="margin",
color_continuous_scale="RdBu_r",
range_color=[-30, 30],
size="total_votes",
size_max=35,
hover_name="county",
hover_data={
"leader": True,
"margin": ":.1f",
"pct_reporting": ":.0f",
"candidate_a_votes": ":,.0f",
"candidate_b_votes": ":,.0f",
"longitude": False,
"latitude": False
},
title="County Results Map"
)
fig_map.update_layout(
xaxis_visible=False,
yaxis_visible=False,
template="plotly_white",
coloraxis_colorbar_title="Margin (%)",
width=700, height=500)
fig_map.show()
Key design choices:
"RdBu_r"colormap (red-blue, reversed) — the standard election map palette. Red for one candidate, blue for the other, white for toss-up counties.range_color=[-30, 30]— centers the diverging colormap at zero and caps at 30 points. Counties won by more than 30 points all appear the same saturated color.size="total_votes"— larger dots for more populous counties, so the visual weight reflects the number of votes, not just geographic area.- Hidden axis labels — geographic coordinates are meaningless to readers; the map shape alone provides spatial context.
- Tooltip excludes lat/lon — setting
"longitude": Falsehides these technical columns from the hover.
Chart 2: The Vote Tally Bar
totals = pd.DataFrame({
"candidate": ["Rivera", "Chen"],
"votes": [
results["candidate_a_votes"].sum(),
results["candidate_b_votes"].sum()
],
"color": ["#D62728", "#1F77B4"]
})
fig_bar = px.bar(
totals, x="candidate", y="votes",
color="candidate",
color_discrete_map={
"Rivera": "#D62728",
"Chen": "#1F77B4"
},
text_auto=":,.0f",
title="Overall Vote Tally"
)
fig_bar.update_layout(
showlegend=False,
yaxis_title="Total Votes",
template="plotly_white")
fig_bar.show()
The bar chart is deliberately simple. On election night, clarity trumps complexity. The audience needs to see two numbers and immediately know who is ahead.
Chart 3: The Demographic Scatter
Anika's editor wants analysis, not just results. The scatter plot connects voting patterns to county demographics:
fig_demo = px.scatter(
results, x="college_pct", y="margin",
color="leader",
color_discrete_map={
"Rivera": "#D62728",
"Chen": "#1F77B4"
},
size="population",
size_max=30,
hover_name="county",
hover_data={
"median_income": ":,.0f",
"urban_pct": ":.0f",
"pct_reporting": ":.0f"
},
opacity=0.7,
title="Education Level vs. Vote Margin"
)
fig_demo.update_layout(
xaxis_title="% with College Degree",
yaxis_title="Margin (+ Rivera, - Chen)",
template="plotly_white")
# Add a horizontal line at zero
fig_demo.add_hline(y=0, line_dash="dash",
line_color="gray",
annotation_text="Toss-up line")
fig_demo.show()
The scatter reveals a clear pattern: counties with higher college attainment tend to favor Chen (negative margin), while less-educated counties favor Rivera. The zero line visually separates the two camps. The size encoding shows that the largest counties (most votes) tend to cluster on the college-educated, Chen-favoring side.
Anika adds a trendline to quantify the relationship:
fig_demo_trend = px.scatter(
results, x="college_pct", y="margin",
color="leader",
color_discrete_map={
"Rivera": "#D62728",
"Chen": "#1F77B4"
},
size="population", size_max=30,
hover_name="county",
trendline="ols",
trendline_scope="overall",
title="Education vs. Margin with Trend"
)
fig_demo_trend.show()
The trendline="ols" parameter adds a linear regression line. trendline_scope="overall" fits a single line to all points regardless of the color grouping. The slope is steep: each additional percentage point of college education corresponds to roughly a 1.5-point shift toward Chen.
Chart 4: The Reporting Progress Bar
Anika needs a way to show how much of the vote is counted:
fig_progress = px.bar(
results.sort_values("pct_reporting"),
x="pct_reporting", y="county",
orientation="h",
color="pct_reporting",
color_continuous_scale="Greens",
hover_data={"total_votes": ":,.0f"},
title="% of Vote Reported by County"
)
fig_progress.update_layout(
xaxis_title="% Reporting",
xaxis_range=[0, 100],
yaxis_title="",
height=800,
template="plotly_white",
showlegend=False)
fig_progress.show()
Counties that are still counting have short, pale bars. Fully reported counties have long, dark bars. This chart helps readers understand how much the results might still change — a county at 40% reporting could swing significantly; one at 98% is essentially final.
Assembly and Election Night
On election night, Anika's workflow is:
- Download updated results CSV every 15 minutes from the election commission's data feed.
- Re-run a notebook that reads the CSV, computes derived columns, and overwrites the four HTML files.
- The newspaper's website embeds these HTML files in iframes that refresh periodically.
The code to regenerate everything from updated data:
def generate_all_charts(csv_path):
df = pd.read_csv(csv_path)
df["margin"] = (
(df["candidate_a_votes"] -
df["candidate_b_votes"]) /
df["total_votes"] * 100)
df["leader"] = df["margin"].apply(
lambda m: "Rivera" if m > 0 else "Chen")
# Generate all four charts
# ... (code from above)
# Export
fig_map.write_html("map.html",
include_plotlyjs="cdn")
fig_bar.write_html("bar.html",
include_plotlyjs=False)
fig_demo.write_html("demo.html",
include_plotlyjs=False)
fig_progress.write_html("progress.html",
include_plotlyjs=False)
By election night, the system is tested and the charts update smoothly. At 10:47 PM, Chen pulls ahead when Clark County (the largest, most urban county) reports its late-evening ballots. The map scatter dot for Clark flips from light red to deep blue, the bar chart shifts, and the overall margin inverts. Readers watching the dashboard see the shift happen in real time.
Post-Election Reflection
After the election, Anika writes a reflection for her journalism portfolio:
What worked: - The tooltip design was critical. Board members and casual readers both praised the ability to hover over a county and see exact numbers without navigating to a separate table. - The demographic scatter was the most shared chart on social media. People found the education-voting correlation striking and shared screenshots (which, importantly, retained the key information even as a static image). - The diverging red-blue colormap was immediately intuitive to American audiences accustomed to election maps.
What she would change:
- The county map as a scatter plot was functional but looked amateurish compared to a real choropleth with county boundaries. For the next election, she would use GeoJSON county boundaries with px.choropleth_mapbox().
- The 15-minute refresh cycle felt slow on election night. A true real-time dashboard would require Dash with server-side callbacks, or a JavaScript-based solution.
- She should have added a "Last updated" timestamp to each chart so readers knew how current the data was.
Pedagogical Reflection
This case study demonstrates several visualization principles beyond the technical:
- Design for the audience. Election night viewers are anxious, distracted, and not data scientists. Every design choice — the two-color scheme, the simple bar chart, the zero-line on the scatter — serves clarity.
- Tooltips replace tables. Instead of a 42-row table of county results, the choropleth map provides geographic context and tooltips provide detail-on-demand.
- Derived columns drive visualization. The
margincolumn (a simple arithmetic calculation) was more informative than raw vote counts for every chart. - Test with sample data first. Anika built and tested the entire system weeks before election night, swapping in sample data. On the actual night, only the data changed — not the code.
- Plan for export and embedding. Knowing that the charts would be embedded in a website shaped the export strategy (CDN for the first chart, no plotly.js for the rest, iframe-friendly sizing).