Static visualizations tell a single story. Interactive dashboards let users explore their own questions, discover unexpected patterns, and engage deeply with the data. For football analytics, where coaches need to filter by opponent, scouts need to...
In This Chapter
- Learning Objectives
- Introduction
- 15.1 The Value of Interactivity
- 15.2 Plotly for Interactive Visualizations
- 15.3 Building Dashboards with Dash
- 15.4 Rapid Prototyping with Streamlit
- 15.5 Design Principles for Interactive Dashboards
- 15.6 Performance Optimization
- 15.7 Deployment Strategies
- 15.8 Complete Dashboard Example
- Chapter Summary
- Key Terms
- Practice Exercises
- Further Reading
Chapter 15: Interactive Dashboards
Learning Objectives
By the end of this chapter, you will be able to:
- Design effective interactive dashboards for football analytics
- Build interactive visualizations using Plotly
- Create web-based dashboards with Dash and Streamlit
- Implement filtering, drilling, and cross-filtering capabilities
- Optimize dashboard performance for large datasets
- Deploy dashboards for team and public access
Introduction
Static visualizations tell a single story. Interactive dashboards let users explore their own questions, discover unexpected patterns, and engage deeply with the data. For football analytics, where coaches need to filter by opponent, scouts need to drill into individual players, and analysts need to explore countless what-if scenarios, interactivity transforms visualizations from passive reports into active exploration tools.
This chapter explores the frameworks and design principles that make interactive dashboards effective. We'll build dashboards that respond instantly to user input, scale to thousands of plays, and deploy to the web where entire organizations can access them.
The goal isn't complexity—it's empowerment. A well-designed interactive dashboard should make users more curious, not more confused.
15.1 The Value of Interactivity
Why Interactive?
Static charts require the creator to anticipate every question. Interactive dashboards let users ask their own:
- "What if we filter to only third-down plays?"
- "How does this player compare to the one I saw yesterday?"
- "What happened in the fourth quarter of that Alabama game?"
These questions emerge during exploration—they can't be predicted in advance. Interactivity enables discovery.
Types of Interaction
Interactive dashboards support several interaction patterns:
Filtering: Narrowing data to specific subsets - By time period (quarter, season, era) - By situation (down, distance, field position) - By entity (team, player, opponent)
Selection: Highlighting specific elements for focus - Clicking a player to see their stats - Selecting a drive to view play-by-play - Choosing comparison groups
Drilling: Moving between levels of detail - From season summary to game summary to play-by-play - From team stats to unit stats to player stats - From aggregate to situational to individual plays
Linking: Connecting views so selection in one affects others - Selecting a game updates all charts - Hovering a player highlights them across all views - Filtering in one panel filters all panels
from dataclasses import dataclass
from typing import List, Dict, Optional
from enum import Enum
class InteractionType(Enum):
"""Classification of dashboard interactions."""
FILTER = "filter"
SELECT = "select"
DRILL = "drill"
LINK = "link"
HOVER = "hover"
ZOOM = "zoom"
@dataclass
class InteractionSpec:
"""Specification for a dashboard interaction."""
type: InteractionType
source_component: str
target_components: List[str]
data_field: str
description: str
When Static Is Better
Interactivity isn't always the answer:
- Final reports: When the story is complete, static is cleaner
- Printed materials: Interactive features don't translate to paper
- Simple messages: One insight doesn't need exploration
- Performance-critical: Static renders faster than interactive
Choose interactivity when exploration adds value, not just complexity.
15.2 Plotly for Interactive Visualizations
Plotly provides interactive charts that work in Jupyter notebooks, web applications, and standalone HTML files.
Getting Started with Plotly
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
# Sample team efficiency data
teams_df = pd.DataFrame({
'team': ['Georgia', 'Ohio State', 'Alabama', 'Michigan', 'Texas',
'Oregon', 'Penn State', 'Florida State', 'Washington', 'USC'],
'offensive_epa': [0.28, 0.25, 0.22, 0.18, 0.21, 0.23, 0.15, 0.19, 0.24, 0.16],
'defensive_epa': [-0.22, -0.18, -0.19, -0.24, -0.15, -0.14, -0.20, -0.16, -0.12, -0.10],
'wins': [13, 12, 11, 13, 12, 12, 10, 13, 14, 8],
'conference': ['SEC', 'Big Ten', 'SEC', 'Big Ten', 'SEC',
'Pac-12', 'Big Ten', 'ACC', 'Pac-12', 'Pac-12']
})
# Basic interactive scatter plot
fig = px.scatter(
teams_df,
x='offensive_epa',
y='defensive_epa',
size='wins',
color='conference',
hover_name='team',
title='Team Efficiency (Hover for Details)',
labels={
'offensive_epa': 'Offensive EPA/Play',
'defensive_epa': 'Defensive EPA/Play'
}
)
fig.update_layout(
hovermode='closest',
template='plotly_white'
)
fig.show()
Interactive Bar Charts
def create_interactive_ranking(df: pd.DataFrame,
value_col: str,
name_col: str,
title: str) -> go.Figure:
"""
Create an interactive horizontal bar chart with hover details.
Args:
df: DataFrame with data
value_col: Column for bar values
name_col: Column for bar labels
title: Chart title
Returns:
Plotly Figure object
"""
# Sort by value
df_sorted = df.sort_values(value_col, ascending=True)
# Color based on value
colors = ['#2a9d8f' if v > 0 else '#e76f51'
for v in df_sorted[value_col]]
fig = go.Figure()
fig.add_trace(go.Bar(
x=df_sorted[value_col],
y=df_sorted[name_col],
orientation='h',
marker_color=colors,
hovertemplate=(
'<b>%{y}</b><br>' +
f'{value_col}: %{{x:.3f}}<br>' +
'<extra></extra>'
)
))
fig.update_layout(
title=title,
xaxis_title=value_col,
yaxis_title='',
template='plotly_white',
height=max(400, len(df) * 25)
)
return fig
Interactive Time Series
def create_win_probability_chart(plays_df: pd.DataFrame,
home_team: str,
away_team: str) -> go.Figure:
"""
Create interactive win probability chart with hover details.
Args:
plays_df: DataFrame with play data including wp_home, game_time, description
home_team: Home team name
away_team: Away team name
Returns:
Plotly Figure with interactive WP chart
"""
fig = go.Figure()
# Win probability line
fig.add_trace(go.Scatter(
x=plays_df['game_time'],
y=plays_df['wp_home'],
mode='lines',
name='Win Probability',
line=dict(color='#264653', width=2),
hovertemplate=(
'<b>%{customdata[0]}</b><br>' +
'Time: %{x}<br>' +
f'{home_team} WP: %{{y:.0%}}<br>' +
'<extra></extra>'
),
customdata=plays_df[['description']].values
))
# 50% reference line
fig.add_hline(y=0.5, line_dash='dash', line_color='gray',
annotation_text='50%', annotation_position='right')
# Fill areas
fig.add_trace(go.Scatter(
x=plays_df['game_time'],
y=[0.5] * len(plays_df),
fill=None,
mode='lines',
line=dict(width=0),
showlegend=False,
hoverinfo='skip'
))
fig.add_trace(go.Scatter(
x=plays_df['game_time'],
y=plays_df['wp_home'],
fill='tonexty',
mode='none',
fillcolor='rgba(42, 157, 143, 0.3)',
showlegend=False,
hoverinfo='skip'
))
fig.update_layout(
title=f'{home_team} vs {away_team} - Win Probability',
xaxis_title='Game Time',
yaxis_title=f'{home_team} Win Probability',
yaxis=dict(tickformat='.0%', range=[0, 1]),
template='plotly_white',
hovermode='x unified'
)
return fig
Linked Selections
from plotly.subplots import make_subplots
def create_linked_dashboard(df: pd.DataFrame) -> go.Figure:
"""
Create dashboard with linked selections across multiple charts.
Clicking a point in one chart highlights the same data in others.
"""
fig = make_subplots(
rows=1, cols=2,
subplot_titles=['Offensive vs Defensive EPA', 'EPA by Conference']
)
# Scatter plot (left)
fig.add_trace(
go.Scatter(
x=df['offensive_epa'],
y=df['defensive_epa'],
mode='markers',
marker=dict(size=12, color=df['wins'], colorscale='Viridis'),
text=df['team'],
name='Teams',
hovertemplate='<b>%{text}</b><br>Off EPA: %{x:.3f}<br>Def EPA: %{y:.3f}'
),
row=1, col=1
)
# Box plot by conference (right)
for conf in df['conference'].unique():
conf_data = df[df['conference'] == conf]
fig.add_trace(
go.Box(
y=conf_data['offensive_epa'],
name=conf,
boxpoints='all',
text=conf_data['team'],
hovertemplate='<b>%{text}</b><br>Off EPA: %{y:.3f}'
),
row=1, col=2
)
fig.update_layout(
height=500,
template='plotly_white',
showlegend=False
)
return fig
15.3 Building Dashboards with Dash
Dash is Plotly's framework for building full web applications with Python.
Dashboard Structure
from dash import Dash, html, dcc, callback, Output, Input, State
import dash_bootstrap_components as dbc
import plotly.express as px
import pandas as pd
# Initialize app with Bootstrap styling
app = Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])
# Sample data
df = pd.DataFrame({
'team': ['Georgia', 'Alabama', 'Ohio State', 'Michigan', 'Texas'],
'wins': [13, 11, 12, 13, 12],
'losses': [1, 2, 1, 0, 2],
'offensive_epa': [0.28, 0.22, 0.25, 0.18, 0.21],
'defensive_epa': [-0.22, -0.19, -0.18, -0.24, -0.15],
'conference': ['SEC', 'SEC', 'Big Ten', 'Big Ten', 'SEC']
})
# Layout
app.layout = dbc.Container([
dbc.Row([
dbc.Col([
html.H1("College Football Analytics Dashboard",
className="text-center mb-4")
])
]),
dbc.Row([
dbc.Col([
html.Label("Select Conference:"),
dcc.Dropdown(
id='conference-filter',
options=[{'label': c, 'value': c}
for c in df['conference'].unique()],
value=None,
placeholder="All Conferences"
)
], width=4),
dbc.Col([
html.Label("Minimum Wins:"),
dcc.Slider(
id='wins-filter',
min=0,
max=df['wins'].max(),
value=0,
marks={i: str(i) for i in range(0, 15, 2)}
)
], width=8)
], className="mb-4"),
dbc.Row([
dbc.Col([
dcc.Graph(id='efficiency-scatter')
], width=6),
dbc.Col([
dcc.Graph(id='wins-bar')
], width=6)
]),
dbc.Row([
dbc.Col([
html.Div(id='selected-team-details')
])
], className="mt-4")
], fluid=True)
@callback(
Output('efficiency-scatter', 'figure'),
Output('wins-bar', 'figure'),
Input('conference-filter', 'value'),
Input('wins-filter', 'value')
)
def update_charts(selected_conference, min_wins):
"""Update both charts based on filter selections."""
# Apply filters
filtered_df = df.copy()
if selected_conference:
filtered_df = filtered_df[filtered_df['conference'] == selected_conference]
filtered_df = filtered_df[filtered_df['wins'] >= min_wins]
# Scatter plot
scatter_fig = px.scatter(
filtered_df,
x='offensive_epa',
y='defensive_epa',
hover_name='team',
color='conference',
size='wins',
title='Team Efficiency'
)
scatter_fig.update_layout(template='plotly_white')
# Bar chart
bar_fig = px.bar(
filtered_df.sort_values('wins', ascending=True),
x='wins',
y='team',
orientation='h',
color='conference',
title='Wins by Team'
)
bar_fig.update_layout(template='plotly_white')
return scatter_fig, bar_fig
@callback(
Output('selected-team-details', 'children'),
Input('efficiency-scatter', 'clickData')
)
def display_team_details(click_data):
"""Show details when a team is clicked."""
if click_data is None:
return html.P("Click a team to see details", className="text-muted")
team_name = click_data['points'][0]['hovertext']
team_data = df[df['team'] == team_name].iloc[0]
return dbc.Card([
dbc.CardHeader(html.H4(team_name)),
dbc.CardBody([
html.P(f"Conference: {team_data['conference']}"),
html.P(f"Record: {team_data['wins']}-{team_data['losses']}"),
html.P(f"Offensive EPA/Play: {team_data['offensive_epa']:.3f}"),
html.P(f"Defensive EPA/Play: {team_data['defensive_epa']:.3f}")
])
])
if __name__ == '__main__':
app.run_server(debug=True)
Cross-Filtering Pattern
@callback(
Output('chart-1', 'figure'),
Output('chart-2', 'figure'),
Output('chart-3', 'figure'),
Input('filter-dropdown', 'value'),
Input('chart-1', 'selectedData'),
Input('chart-2', 'selectedData')
)
def cross_filter_charts(filter_value, selection_1, selection_2):
"""
Implement cross-filtering: selection in any chart filters all others.
"""
# Start with base filter
mask = df['conference'] == filter_value if filter_value else True
# Apply selection from chart 1
if selection_1 and selection_1.get('points'):
selected_teams = [p['hovertext'] for p in selection_1['points']]
mask = mask & df['team'].isin(selected_teams)
# Apply selection from chart 2
if selection_2 and selection_2.get('points'):
selected_teams = [p['text'] for p in selection_2['points']]
mask = mask & df['team'].isin(selected_teams)
filtered = df[mask]
# Generate all three figures with filtered data
fig1 = create_chart_1(filtered)
fig2 = create_chart_2(filtered)
fig3 = create_chart_3(filtered)
return fig1, fig2, fig3
Drill-Down Pattern
from dash import ctx
# Store for tracking drill-down state
app.layout = html.Div([
dcc.Store(id='drill-level', data='conference'), # conference -> team -> player
dcc.Store(id='selected-entity', data=None),
html.H2(id='breadcrumb'),
html.Button('Back', id='back-button'),
dcc.Graph(id='drill-chart')
])
@callback(
Output('drill-level', 'data'),
Output('selected-entity', 'data'),
Output('drill-chart', 'figure'),
Output('breadcrumb', 'children'),
Input('drill-chart', 'clickData'),
Input('back-button', 'n_clicks'),
State('drill-level', 'data'),
State('selected-entity', 'data')
)
def handle_drill(click_data, back_clicks, current_level, current_entity):
"""Handle drill-down and drill-up navigation."""
triggered = ctx.triggered_id
if triggered == 'back-button':
# Drill up
if current_level == 'player':
return 'team', current_entity.split('|')[0], create_team_view(current_entity), f"Team: {current_entity}"
elif current_level == 'team':
return 'conference', None, create_conference_view(), "All Conferences"
else:
return 'conference', None, create_conference_view(), "All Conferences"
elif triggered == 'drill-chart' and click_data:
# Drill down
clicked_name = click_data['points'][0].get('hovertext', '')
if current_level == 'conference':
return 'team', clicked_name, create_team_view(clicked_name), f"Conference: {clicked_name}"
elif current_level == 'team':
return 'player', f"{current_entity}|{clicked_name}", create_player_view(clicked_name), f"Player: {clicked_name}"
# Default
return current_level, current_entity, create_conference_view(), "All Conferences"
15.4 Rapid Prototyping with Streamlit
Streamlit enables rapid dashboard creation with minimal code.
Basic Streamlit Dashboard
# streamlit_dashboard.py
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
st.set_page_config(
page_title="CFB Analytics",
page_icon="🏈",
layout="wide"
)
st.title("🏈 College Football Analytics Dashboard")
# Sidebar filters
st.sidebar.header("Filters")
# Load data (cached)
@st.cache_data
def load_data():
# In practice, load from database or file
return pd.DataFrame({
'team': ['Georgia', 'Alabama', 'Ohio State', 'Michigan', 'Texas',
'Oregon', 'Penn State', 'Washington', 'Florida State', 'USC'],
'conference': ['SEC', 'SEC', 'Big Ten', 'Big Ten', 'SEC',
'Pac-12', 'Big Ten', 'Pac-12', 'ACC', 'Pac-12'],
'wins': [13, 11, 12, 13, 12, 12, 10, 14, 13, 8],
'losses': [1, 2, 1, 0, 2, 1, 3, 0, 0, 5],
'offensive_epa': [0.28, 0.22, 0.25, 0.18, 0.21, 0.23, 0.15, 0.24, 0.19, 0.16],
'defensive_epa': [-0.22, -0.19, -0.18, -0.24, -0.15, -0.14, -0.20, -0.12, -0.16, -0.10]
})
df = load_data()
# Sidebar controls
conferences = st.sidebar.multiselect(
"Select Conferences",
options=df['conference'].unique(),
default=df['conference'].unique()
)
min_wins = st.sidebar.slider(
"Minimum Wins",
min_value=0,
max_value=int(df['wins'].max()),
value=0
)
# Apply filters
filtered_df = df[
(df['conference'].isin(conferences)) &
(df['wins'] >= min_wins)
]
# Key metrics
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric(
"Teams Shown",
len(filtered_df),
delta=f"{len(filtered_df) - len(df)} from all"
)
with col2:
st.metric(
"Avg Wins",
f"{filtered_df['wins'].mean():.1f}"
)
with col3:
st.metric(
"Best Off EPA",
f"{filtered_df['offensive_epa'].max():.3f}",
delta=f"{filtered_df.loc[filtered_df['offensive_epa'].idxmax(), 'team']}"
)
with col4:
st.metric(
"Best Def EPA",
f"{filtered_df['defensive_epa'].min():.3f}",
delta=f"{filtered_df.loc[filtered_df['defensive_epa'].idxmin(), 'team']}"
)
# Charts
col_left, col_right = st.columns(2)
with col_left:
st.subheader("Team Efficiency")
fig_scatter = px.scatter(
filtered_df,
x='offensive_epa',
y='defensive_epa',
size='wins',
color='conference',
hover_name='team',
title='Offensive vs Defensive EPA'
)
fig_scatter.update_layout(template='plotly_white')
st.plotly_chart(fig_scatter, use_container_width=True)
with col_right:
st.subheader("Wins Distribution")
fig_bar = px.bar(
filtered_df.sort_values('wins', ascending=True),
x='wins',
y='team',
orientation='h',
color='conference',
title='Wins by Team'
)
fig_bar.update_layout(template='plotly_white')
st.plotly_chart(fig_bar, use_container_width=True)
# Detailed data table
st.subheader("Team Data")
st.dataframe(
filtered_df.style.format({
'offensive_epa': '{:.3f}',
'defensive_epa': '{:.3f}'
}),
use_container_width=True
)
# Team comparison
st.subheader("Compare Teams")
compare_teams = st.multiselect(
"Select teams to compare",
options=filtered_df['team'].tolist(),
default=filtered_df['team'].tolist()[:2] if len(filtered_df) >= 2 else []
)
if len(compare_teams) >= 2:
compare_df = filtered_df[filtered_df['team'].isin(compare_teams)]
fig_radar = go.Figure()
categories = ['Wins', 'Off EPA', 'Def EPA']
for team in compare_teams:
team_data = compare_df[compare_df['team'] == team].iloc[0]
values = [
team_data['wins'] / 14, # Normalize
(team_data['offensive_epa'] + 0.3) / 0.6, # Normalize
(-team_data['defensive_epa'] + 0.3) / 0.6 # Normalize (invert)
]
values.append(values[0]) # Close polygon
fig_radar.add_trace(go.Scatterpolar(
r=values,
theta=categories + [categories[0]],
name=team
))
fig_radar.update_layout(
polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
showlegend=True,
title="Team Comparison Radar"
)
st.plotly_chart(fig_radar, use_container_width=True)
Running Streamlit
# Run the dashboard
streamlit run streamlit_dashboard.py
# With custom port
streamlit run streamlit_dashboard.py --server.port 8080
Streamlit Components
# Multi-page app structure
# pages/1_Team_Analysis.py
# pages/2_Player_Stats.py
# pages/3_Game_Breakdown.py
# Session state for persistence
if 'selected_team' not in st.session_state:
st.session_state.selected_team = None
# Update state
if st.button('Select Georgia'):
st.session_state.selected_team = 'Georgia'
# Custom components with callbacks
selection = st.selectbox(
'Select team',
options=df['team'].tolist(),
on_change=lambda: update_charts()
)
# Download button
csv = filtered_df.to_csv(index=False)
st.download_button(
label="Download data as CSV",
data=csv,
file_name='team_data.csv',
mime='text/csv'
)
15.5 Design Principles for Interactive Dashboards
Progressive Disclosure
Start simple, reveal complexity on demand:
# Level 1: Summary view (default)
st.subheader("Season Summary")
show_summary_metrics(df)
# Level 2: Detailed view (on expansion)
with st.expander("View Detailed Statistics"):
show_detailed_table(df)
# Level 3: Play-by-play (on demand)
if st.button("Load Play-by-Play Data"):
with st.spinner("Loading..."):
pbp_data = load_play_by_play()
show_play_by_play(pbp_data)
Responsive Feedback
Users should never wonder if their action worked:
# Show loading state
with st.spinner('Updating charts...'):
fig = create_complex_chart(filtered_data)
st.plotly_chart(fig)
# Confirm actions
if st.button("Export Report"):
with st.spinner("Generating report..."):
report = generate_report(df)
st.success("Report generated successfully!")
st.download_button("Download Report", report)
# Show empty states
if filtered_df.empty:
st.warning("No teams match your filters. Try adjusting the criteria.")
else:
show_charts(filtered_df)
Consistent Interactions
Same gestures should have same effects throughout:
class DashboardStyle:
"""Maintain consistent interaction patterns."""
# Hover behavior
HOVER_TEMPLATE = '<b>%{hovertext}</b><br>%{customdata[0]}<extra></extra>'
# Selection colors
SELECTED_COLOR = '#264653'
UNSELECTED_COLOR = '#adb5bd'
# Animation duration
TRANSITION_MS = 300
@classmethod
def apply_to_figure(cls, fig: go.Figure):
"""Apply consistent styling to any figure."""
fig.update_layout(
template='plotly_white',
hovermode='closest',
transition={'duration': cls.TRANSITION_MS}
)
return fig
Information Hierarchy
Guide users to the most important information first:
def create_dashboard_layout():
"""
Create layout with clear information hierarchy.
1. Key metrics at top (glanceable)
2. Primary visualization (main insight)
3. Secondary visualizations (supporting context)
4. Detailed data (on-demand)
"""
return html.Div([
# Level 1: Key Metrics (Always Visible)
html.Div([
create_kpi_cards()
], className='kpi-row'),
# Level 2: Primary Visualization
html.Div([
dcc.Graph(id='main-chart', figure=create_main_chart())
], className='main-chart'),
# Level 3: Secondary Visualizations
html.Div([
dcc.Graph(id='chart-2'),
dcc.Graph(id='chart-3')
], className='secondary-row'),
# Level 4: Detailed Data (Collapsed by Default)
html.Details([
html.Summary("View Detailed Data"),
create_data_table()
])
])
15.6 Performance Optimization
Data Loading Strategies
import functools
# Caching in Streamlit
@st.cache_data(ttl=3600) # Cache for 1 hour
def load_season_data(season: int) -> pd.DataFrame:
"""Load season data with caching."""
return pd.read_parquet(f's3://data/season_{season}.parquet')
# Lazy loading in Dash
@callback(
Output('play-table', 'data'),
Input('load-plays-button', 'n_clicks'),
State('selected-game', 'value'),
prevent_initial_call=True
)
def load_plays_on_demand(n_clicks, game_id):
"""Only load play data when explicitly requested."""
if n_clicks and game_id:
return load_play_by_play(game_id).to_dict('records')
return []
# Pagination for large datasets
PAGE_SIZE = 50
@callback(
Output('data-table', 'data'),
Input('data-table', 'page_current'),
Input('data-table', 'page_size')
)
def paginate_data(page, size):
"""Return only current page of data."""
start = page * size
end = start + size
return df.iloc[start:end].to_dict('records')
Efficient Chart Updates
from dash import no_update
@callback(
Output('chart', 'figure'),
Input('filter', 'value'),
State('chart', 'figure')
)
def efficient_update(filter_value, current_figure):
"""Update only what changed, not the entire figure."""
if filter_value is None:
return no_update
# Modify existing figure rather than recreating
new_fig = go.Figure(current_figure)
# Update only the data that changed
filtered_data = df[df['conference'] == filter_value]
new_fig.data[0].x = filtered_data['offensive_epa']
new_fig.data[0].y = filtered_data['defensive_epa']
return new_fig
# Use clientside callbacks for simple updates
app.clientside_callback(
"""
function(clickData) {
if (!clickData) return window.dash_clientside.no_update;
// Handle click entirely in browser
return clickData.points[0].hovertext;
}
""",
Output('selected-team-text', 'children'),
Input('chart', 'clickData')
)
Data Aggregation
def optimize_for_visualization(df: pd.DataFrame,
max_points: int = 1000) -> pd.DataFrame:
"""
Reduce data size while preserving visual patterns.
For large datasets, aggregate or sample intelligently.
"""
if len(df) <= max_points:
return df
# Option 1: Sampling (for exploration)
if len(df) < max_points * 10:
return df.sample(max_points)
# Option 2: Aggregation (for time series)
if 'timestamp' in df.columns:
# Resample to reduce points
df = df.set_index('timestamp')
resample_freq = calculate_resample_freq(len(df), max_points)
return df.resample(resample_freq).mean().reset_index()
# Option 3: Binning (for scatter plots)
return bin_for_heatmap(df, bins=50)
15.7 Deployment Strategies
Streamlit Cloud
# requirements.txt
streamlit==1.28.0
pandas==2.0.0
plotly==5.18.0
# .streamlit/config.toml
[server]
maxUploadSize = 50
enableCORS = false
[theme]
primaryColor = "#264653"
backgroundColor = "#ffffff"
Docker Deployment
# Dockerfile
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8050
CMD ["python", "app.py"]
# app.py for production
if __name__ == '__main__':
app.run_server(
host='0.0.0.0',
port=8050,
debug=False
)
Authentication
# Basic authentication with Dash
import dash_auth
VALID_USERS = {
'analyst': 'secure_password',
'coach': 'coach_password'
}
auth = dash_auth.BasicAuth(app, VALID_USERS)
# Role-based access
@callback(
Output('admin-panel', 'style'),
Input('url', 'pathname')
)
def show_admin_panel(pathname):
# Check user role
if current_user_is_admin():
return {'display': 'block'}
return {'display': 'none'}
15.8 Complete Dashboard Example
"""
Complete interactive dashboard combining all concepts.
"""
from dash import Dash, html, dcc, callback, Output, Input, 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
# Initialize
app = Dash(__name__, external_stylesheets=[dbc.themes.FLATLY])
# Sample data
np.random.seed(42)
teams = ['Georgia', 'Alabama', 'Ohio State', 'Michigan', 'Texas',
'Oregon', 'Penn State', 'Washington', 'Florida State', 'USC']
conferences = ['SEC', 'SEC', 'Big Ten', 'Big Ten', 'SEC',
'Pac-12', 'Big Ten', 'Pac-12', 'ACC', 'Pac-12']
df = pd.DataFrame({
'team': teams,
'conference': conferences,
'wins': np.random.randint(8, 14, 10),
'offensive_epa': np.random.uniform(0.1, 0.3, 10),
'defensive_epa': np.random.uniform(-0.25, -0.1, 10),
'special_teams_epa': np.random.uniform(-0.05, 0.05, 10)
})
# Layout
app.layout = dbc.Container([
# Header
dbc.Row([
dbc.Col([
html.H1("College Football Analytics", className="display-4"),
html.P("Interactive team performance dashboard", className="lead")
], className="text-center my-4")
]),
# Filters
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
html.H5("Filters", className="card-title"),
html.Label("Conference"),
dcc.Dropdown(
id='conf-filter',
options=[{'label': c, 'value': c}
for c in df['conference'].unique()],
multi=True,
placeholder="All Conferences"
),
html.Label("Minimum Wins", className="mt-3"),
dcc.Slider(
id='wins-slider',
min=0, max=14, value=0,
marks={i: str(i) for i in range(0, 15, 2)}
)
])
])
], width=12)
], className="mb-4"),
# KPI Cards
dbc.Row([
dbc.Col([dbc.Card([
dbc.CardBody([
html.H2(id='teams-count', className="text-primary"),
html.P("Teams Shown")
])
])], width=3),
dbc.Col([dbc.Card([
dbc.CardBody([
html.H2(id='avg-wins', className="text-success"),
html.P("Avg Wins")
])
])], width=3),
dbc.Col([dbc.Card([
dbc.CardBody([
html.H2(id='best-offense', className="text-info"),
html.P("Best Off EPA")
])
])], width=3),
dbc.Col([dbc.Card([
dbc.CardBody([
html.H2(id='best-defense', className="text-warning"),
html.P("Best Def EPA")
])
])], width=3)
], className="mb-4"),
# Charts
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardBody([
dcc.Graph(id='efficiency-scatter')
])
])
], width=6),
dbc.Col([
dbc.Card([
dbc.CardBody([
dcc.Graph(id='conference-box')
])
])
], width=6)
], className="mb-4"),
# Team Details
dbc.Row([
dbc.Col([
dbc.Card([
dbc.CardHeader("Team Details (Click a point above)"),
dbc.CardBody(id='team-details')
])
])
])
], fluid=True)
@callback(
Output('teams-count', 'children'),
Output('avg-wins', 'children'),
Output('best-offense', 'children'),
Output('best-defense', 'children'),
Output('efficiency-scatter', 'figure'),
Output('conference-box', 'figure'),
Input('conf-filter', 'value'),
Input('wins-slider', 'value')
)
def update_dashboard(conferences, min_wins):
# Filter data
filtered = df.copy()
if conferences:
filtered = filtered[filtered['conference'].isin(conferences)]
filtered = filtered[filtered['wins'] >= min_wins]
# KPIs
teams_count = str(len(filtered))
avg_wins = f"{filtered['wins'].mean():.1f}" if len(filtered) > 0 else "N/A"
best_off = f"{filtered['offensive_epa'].max():.3f}" if len(filtered) > 0 else "N/A"
best_def = f"{filtered['defensive_epa'].min():.3f}" if len(filtered) > 0 else "N/A"
# Scatter plot
scatter_fig = px.scatter(
filtered,
x='offensive_epa',
y='defensive_epa',
size='wins',
color='conference',
hover_name='team',
title='Team Efficiency (Click for details)'
)
scatter_fig.update_layout(template='plotly_white', clickmode='event')
# Box plot
box_fig = px.box(
filtered,
x='conference',
y='offensive_epa',
color='conference',
title='Offensive EPA by Conference'
)
box_fig.update_layout(template='plotly_white', showlegend=False)
return teams_count, avg_wins, best_off, best_def, scatter_fig, box_fig
@callback(
Output('team-details', 'children'),
Input('efficiency-scatter', 'clickData')
)
def show_team_details(click_data):
if not click_data:
return html.P("Click a team on the scatter plot to see details.",
className="text-muted")
team_name = click_data['points'][0]['hovertext']
team = df[df['team'] == team_name].iloc[0]
return html.Div([
html.H4(team_name),
html.Hr(),
dbc.Row([
dbc.Col([
html.P(f"Conference: {team['conference']}"),
html.P(f"Wins: {team['wins']}")
]),
dbc.Col([
html.P(f"Offensive EPA: {team['offensive_epa']:.3f}"),
html.P(f"Defensive EPA: {team['defensive_epa']:.3f}"),
html.P(f"Special Teams EPA: {team['special_teams_epa']:.3f}")
])
])
])
if __name__ == '__main__':
app.run_server(debug=True)
Chapter Summary
Interactive dashboards transform football analytics from static reports into exploration tools:
- Plotly provides rich interactive charts with hover, zoom, and selection
- Dash enables full web applications with callbacks and state management
- Streamlit offers rapid prototyping with minimal code
- Design principles guide users through progressive disclosure and responsive feedback
- Performance optimization ensures dashboards remain fast with large datasets
- Deployment makes dashboards accessible to entire organizations
The best dashboards don't just display data—they invite questions and reward exploration.
Key Terms
- Callback: Function triggered by user interaction that updates dashboard components
- Cross-filtering: Selection in one chart filters data in all other charts
- Drill-down: Navigation from summary to detailed views
- Progressive disclosure: Revealing complexity gradually as users request it
- State management: Tracking user selections and navigation across interactions
Practice Exercises
- Create a Plotly scatter plot with hover tooltips showing play details
- Build a Dash dashboard with conference and team filters
- Implement cross-filtering between a scatter plot and bar chart
- Create a Streamlit app with caching for large datasets
- Deploy a dashboard to Streamlit Cloud
Further Reading
- Plotly Documentation: https://plotly.com/python/
- Dash User Guide: https://dash.plotly.com/
- Streamlit Documentation: https://docs.streamlit.io/
- Interactive Data Visualization, Murray (O'Reilly)