Creating Shot Charts
Beginner
10 min read
1 views
Nov 27, 2025
# Shot Charts Basics
## Introduction
Shot charts are fundamental visualizations in basketball analytics that display the spatial distribution of shot attempts and their outcomes on a representation of the basketball court. They provide insights into shooting patterns, player tendencies, offensive strategies, and defensive schemes.
## What Are Shot Charts?
Shot charts are visual representations that map where shots are taken on a basketball court and whether they were made or missed. They serve multiple purposes:
- **Player Evaluation**: Identify shooting strengths and weaknesses
- **Scouting**: Understand opponent tendencies and preferences
- **Strategy Development**: Design offensive plays and defensive schemes
- **Performance Tracking**: Monitor improvement over time
- **Fan Engagement**: Create compelling visual narratives
### Key Components
1. **Court Representation**: A scaled diagram of the basketball court
2. **Shot Locations**: X,Y coordinates marking where shots were taken
3. **Shot Outcomes**: Visual indicators (color, size, shape) showing makes/misses
4. **Shot Types**: Different markers for 2-pointers, 3-pointers, free throws
5. **Contextual Data**: Shot distance, time remaining, defender distance
## Court Coordinate Systems
Understanding coordinate systems is crucial for creating accurate shot charts.
### NBA Coordinate System
The NBA uses a coordinate system where:
- **Origin (0,0)**: Center of the basket
- **X-axis**: Horizontal (left-right), ranges from -250 to 250 (in inches/10)
- **Y-axis**: Vertical (baseline-baseline), ranges from -47.5 to 892.5 (in inches/10)
- **Units**: Tenths of feet (1 unit = 1.2 inches)
```
Court Dimensions (in coordinate units):
- Court length: 940 units (94 feet)
- Court width: 500 units (50 feet)
- 3-point line: 237.5 units (23.75 feet) from basket at top
- Paint width: 160 units (16 feet)
- Free throw line: 142.5 units (14.25 feet) from baseline
```
### Converting Coordinates
**From raw coordinates to visualization:**
```
visual_x = (x + 250) / 500 * court_width
visual_y = y / 940 * court_length
```
**Distance from basket:**
```
distance = sqrt(x^2 + y^2)
```
**Shot angle:**
```
angle = atan2(y, x)
```
## Visualization Techniques
### 1. Scatter Plots
The simplest form of shot chart plotting individual shots as points.
**Advantages:**
- Shows exact shot locations
- Easy to distinguish makes vs. misses
- Good for small sample sizes
- Preserves individual shot data
**Disadvantages:**
- Cluttered with large datasets
- Difficult to identify patterns with many shots
- Overlapping points obscure density
**Best Use Cases:**
- Single game analysis
- Small sample sizes (< 100 shots)
- When exact locations matter
### 2. Hexbin (Hexagonal Binning)
Divides the court into hexagonal bins and aggregates shots within each bin.
**Advantages:**
- Handles large datasets effectively
- Shows shot density clearly
- Reduces visual clutter
- Hexagons tile perfectly without gaps
**Disadvantages:**
- Loses individual shot details
- Bin size affects interpretation
- Less intuitive than scatter plots
**Best Use Cases:**
- Season-long analysis
- Large sample sizes (> 500 shots)
- Identifying shooting zones
**Key Parameters:**
- **Gridsize**: Number of hexagons (10-30 typical)
- **Color mapping**: Frequency or shooting percentage
- **Bin calculation**: Count, percentage, or efficiency
### 3. Heat Maps
Continuous color gradients representing shot frequency or efficiency.
**Advantages:**
- Smooth, visually appealing
- Shows gradual transitions in shooting
- Good for presentations
- Easy to interpret at a glance
**Disadvantages:**
- Can be misleading with small samples
- Smoothing may hide important details
- Computationally intensive
**Best Use Cases:**
- Public-facing visualizations
- Comparing multiple players
- Marketing and media
**Implementation Techniques:**
- **Kernel Density Estimation (KDE)**: Smooth probability density
- **Gaussian blur**: Apply smoothing to binned data
- **Interpolation**: Fill gaps between data points
### 4. Zone-Based Analysis
Divides the court into predefined zones based on basketball strategy.
**Common Zone Definitions:**
1. **Simple Zones**:
- Paint (restricted area)
- Mid-range (2-point outside paint)
- 3-point (beyond arc)
2. **Detailed Zones** (6-8 zones):
- Restricted area
- Paint (non-restricted)
- Short mid-range (< 16 feet)
- Long mid-range (16-23 feet)
- Corner 3 (both sides)
- Above-the-break 3
3. **Strategic Zones** (14+ zones):
- Separate left/right sides
- Include wing, top, elbow positions
- Distinguish baseline vs. mid-range
**Zone Analysis Metrics:**
- Attempts per zone
- Field goal percentage by zone
- Points per shot by zone
- Shot distribution (% of total attempts)
- Efficiency metrics (eFG%, True Shooting %)
## Interpreting Shot Charts
### Reading Basic Shot Charts
**What to Look For:**
1. **Hot Zones**: Areas with high frequency and/or high efficiency
2. **Cold Zones**: Areas with low efficiency or avoidance
3. **Shot Distribution**: Spread vs. concentration of attempts
4. **Range**: Maximum distance from basket
5. **Asymmetry**: Left/right or top/bottom preferences
### Advanced Interpretation
**Shot Selection Quality:**
- Compare actual FG% to expected FG% by zone
- Evaluate shot distribution relative to league averages
- Assess balance between volume and efficiency
**Matchup Analysis:**
- Compare player tendencies to defensive coverage
- Identify exploitable mismatches
- Design defensive game plans
**Temporal Patterns:**
- Early vs. late game shot selection
- Shot location changes when leading/trailing
- Clutch shooting zones
### Common Pitfalls
1. **Sample Size**: Small samples lead to unreliable patterns
2. **Context Ignorance**: Not accounting for defense, game situation
3. **Overcomplexity**: Too many colors/bins obscures insights
4. **Scale Issues**: Improper color scaling misleads interpretation
5. **Selection Bias**: Filtered data may not represent true tendencies
## Python Implementation
### Using matplotlib and nba_api
```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle, Arc, Polygon
from nba_api.stats.endpoints import shotchartdetail
from nba_api.stats.static import players, teams
# Function to draw NBA court
def draw_court(ax=None, color='black', lw=2, outer_lines=False):
"""
Draw an NBA basketball court.
Parameters:
-----------
ax : matplotlib axes object
color : court line color
lw : line width
outer_lines : include outer court boundaries
"""
if ax is None:
ax = plt.gca()
# Basketball hoop
hoop = Circle((0, 0), radius=7.5, linewidth=lw, color=color, fill=False)
# Backboard
backboard = Rectangle((-30, -7.5), 60, -1, linewidth=lw, color=color)
# Paint/Lane
outer_box = Rectangle((-80, -47.5), 160, 190, linewidth=lw,
color=color, fill=False)
inner_box = Rectangle((-60, -47.5), 120, 190, linewidth=lw,
color=color, fill=False)
# Free throw circle
top_free_throw = Arc((0, 142.5), 120, 120, theta1=0, theta2=180,
linewidth=lw, color=color, fill=False)
bottom_free_throw = Arc((0, 142.5), 120, 120, theta1=180, theta2=0,
linewidth=lw, color=color, linestyle='dashed')
# Restricted area
restricted = Arc((0, 0), 80, 80, theta1=0, theta2=180,
linewidth=lw, color=color)
# Three point line
corner_three_a = Rectangle((-220, -47.5), 0, 140, linewidth=lw,
color=color)
corner_three_b = Rectangle((220, -47.5), 0, 140, linewidth=lw,
color=color)
three_arc = Arc((0, 0), 475, 475, theta1=22, theta2=158,
linewidth=lw, color=color)
# Center court
center_outer = Arc((0, 422.5), 120, 120, theta1=180, theta2=0,
linewidth=lw, color=color)
center_inner = Arc((0, 422.5), 40, 40, theta1=180, theta2=0,
linewidth=lw, color=color)
# Court elements list
court_elements = [hoop, backboard, outer_box, inner_box,
top_free_throw, bottom_free_throw, restricted,
corner_three_a, corner_three_b, three_arc,
center_outer, center_inner]
if outer_lines:
outer = Rectangle((-250, -47.5), 500, 470, linewidth=lw,
color=color, fill=False)
court_elements.append(outer)
for element in court_elements:
ax.add_patch(element)
return ax
# Fetch shot chart data
def get_player_shot_data(player_name, season='2023-24'):
"""
Fetch shot chart data for a player.
Parameters:
-----------
player_name : str
Player's full name
season : str
NBA season (format: 'YYYY-YY')
Returns:
--------
DataFrame with shot data
"""
# Find player ID
player_dict = players.get_players()
player = [p for p in player_dict if p['full_name'] == player_name][0]
player_id = player['id']
# Fetch shot data
shot_data = shotchartdetail.ShotChartDetail(
team_id=0,
player_id=player_id,
season_nullable=season,
season_type_all_star='Regular Season',
context_measure_simple='FGA'
)
shots_df = shot_data.get_data_frames()[0]
return shots_df
# 1. Basic Scatter Plot Shot Chart
def create_scatter_shot_chart(shots_df, player_name, save_path=None):
"""
Create a basic scatter plot shot chart.
"""
fig, ax = plt.subplots(figsize=(12, 11))
# Draw court
draw_court(ax, outer_lines=True)
# Plot shots
made_shots = shots_df[shots_df['SHOT_MADE_FLAG'] == 1]
missed_shots = shots_df[shots_df['SHOT_MADE_FLAG'] == 0]
ax.scatter(missed_shots['LOC_X'], missed_shots['LOC_Y'],
c='red', marker='x', s=50, alpha=0.5, label='Miss')
ax.scatter(made_shots['LOC_X'], made_shots['LOC_Y'],
c='green', marker='o', s=50, alpha=0.5, label='Make')
# Calculate shooting percentage
fg_pct = (len(made_shots) / len(shots_df)) * 100
# Styling
ax.set_xlim(-250, 250)
ax.set_ylim(-47.5, 422.5)
ax.set_aspect('equal')
ax.axis('off')
ax.legend(loc='upper right')
ax.set_title(f'{player_name} Shot Chart\n'
f'FG: {len(made_shots)}/{len(shots_df)} ({fg_pct:.1f}%)',
fontsize=16, fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig, ax
# 2. Hexbin Shot Chart
def create_hexbin_shot_chart(shots_df, player_name, save_path=None):
"""
Create a hexbin shot chart showing shot frequency.
"""
fig, ax = plt.subplots(figsize=(12, 11))
# Draw court
draw_court(ax, color='white', lw=2, outer_lines=True)
# Create hexbin
hexbin = ax.hexbin(shots_df['LOC_X'], shots_df['LOC_Y'],
gridsize=25, cmap='YlOrRd', alpha=0.8,
edgecolors='black', linewidths=0.5)
# Add colorbar
cbar = plt.colorbar(hexbin, ax=ax, orientation='horizontal',
pad=0.05, aspect=30)
cbar.set_label('Shot Attempts', fontsize=12, fontweight='bold')
# Styling
ax.set_xlim(-250, 250)
ax.set_ylim(-47.5, 422.5)
ax.set_aspect('equal')
ax.set_facecolor('#f0f0f0')
ax.axis('off')
ax.set_title(f'{player_name} Shot Frequency Hexbin\n'
f'Total Shots: {len(shots_df)}',
fontsize=16, fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig, ax
# 3. Shooting Percentage Hexbin
def create_accuracy_hexbin(shots_df, player_name, min_shots=5, save_path=None):
"""
Create a hexbin showing shooting percentage by location.
"""
fig, ax = plt.subplots(figsize=(12, 11))
# Draw court
draw_court(ax, color='black', lw=2, outer_lines=True)
# Calculate shooting percentage for hexbins
x = shots_df['LOC_X'].values
y = shots_df['LOC_Y'].values
made = shots_df['SHOT_MADE_FLAG'].values
# Create custom hexbin with FG%
hexbin = ax.hexbin(x, y, C=made, gridsize=20,
reduce_C_function=np.mean,
cmap='RdYlGn', alpha=0.8,
edgecolors='black', linewidths=0.5,
vmin=0, vmax=1, mincnt=min_shots)
# Add colorbar
cbar = plt.colorbar(hexbin, ax=ax, orientation='horizontal',
pad=0.05, aspect=30)
cbar.set_label('Field Goal %', fontsize=12, fontweight='bold')
cbar.set_ticks([0, 0.25, 0.5, 0.75, 1.0])
cbar.set_ticklabels(['0%', '25%', '50%', '75%', '100%'])
# Styling
ax.set_xlim(-250, 250)
ax.set_ylim(-47.5, 422.5)
ax.set_aspect('equal')
ax.set_facecolor('#cccccc')
ax.axis('off')
ax.set_title(f'{player_name} Shooting Accuracy by Location\n'
f'(Minimum {min_shots} attempts per hexagon)',
fontsize=16, fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig, ax
# 4. Heat Map with KDE
from scipy.stats import gaussian_kde
def create_heatmap_shot_chart(shots_df, player_name, save_path=None):
"""
Create a smooth heat map using Kernel Density Estimation.
"""
fig, ax = plt.subplots(figsize=(12, 11))
# Prepare data
x = shots_df['LOC_X'].values
y = shots_df['LOC_Y'].values
# Create grid
xi = np.linspace(-250, 250, 200)
yi = np.linspace(-47.5, 422.5, 200)
Xi, Yi = np.meshgrid(xi, yi)
# Calculate KDE
positions = np.vstack([Xi.ravel(), Yi.ravel()])
values = np.vstack([x, y])
kernel = gaussian_kde(values)
Zi = np.reshape(kernel(positions).T, Xi.shape)
# Draw heat map
im = ax.contourf(Xi, Yi, Zi, levels=20, cmap='YlOrRd', alpha=0.7)
# Draw court on top
draw_court(ax, color='black', lw=2, outer_lines=True)
# Add colorbar
cbar = plt.colorbar(im, ax=ax, orientation='horizontal',
pad=0.05, aspect=30)
cbar.set_label('Shot Density', fontsize=12, fontweight='bold')
# Styling
ax.set_xlim(-250, 250)
ax.set_ylim(-47.5, 422.5)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(f'{player_name} Shot Heat Map\n'
f'Total Shots: {len(shots_df)}',
fontsize=16, fontweight='bold')
plt.tight_layout()
if save_path:
plt.savefig(save_path, dpi=300, bbox_inches='tight')
return fig, ax
# 5. Zone-Based Analysis
def create_zone_analysis(shots_df, player_name):
"""
Analyze shooting by predefined court zones.
"""
def assign_zone(row):
x = row['LOC_X']
y = row['LOC_Y']
dist = np.sqrt(x**2 + y**2)
# Restricted area
if dist <= 40:
return 'Restricted Area'
# Paint (non-restricted)
elif abs(x) <= 80 and y <= 142.5 and dist > 40:
return 'Paint'
# Short mid-range
elif dist <= 160:
return 'Short Mid-Range'
# Long mid-range
elif dist <= 237.5:
return 'Long Mid-Range'
# Corner 3
elif abs(x) >= 220 and y <= 92.5:
return 'Corner 3'
# Above-the-break 3
else:
return 'Above-Break 3'
# Assign zones
shots_df['ZONE'] = shots_df.apply(assign_zone, axis=1)
# Calculate statistics by zone
zone_stats = shots_df.groupby('ZONE').agg({
'SHOT_MADE_FLAG': ['sum', 'count', 'mean']
}).round(3)
zone_stats.columns = ['Makes', 'Attempts', 'FG%']
zone_stats['Points_Per_Shot'] = zone_stats.apply(
lambda row: row['FG%'] * (3 if '3' in row.name else 2), axis=1
).round(3)
zone_stats['Frequency'] = (zone_stats['Attempts'] /
zone_stats['Attempts'].sum() * 100).round(1)
# Sort by frequency
zone_stats = zone_stats.sort_values('Attempts', ascending=False)
print(f"\n{player_name} - Zone Analysis")
print("=" * 80)
print(zone_stats.to_string())
return zone_stats
# Example usage
if __name__ == '__main__':
# Fetch data
player_name = 'Stephen Curry'
shots_df = get_player_shot_data(player_name, season='2023-24')
# Create visualizations
create_scatter_shot_chart(shots_df, player_name, 'curry_scatter.png')
create_hexbin_shot_chart(shots_df, player_name, 'curry_hexbin.png')
create_accuracy_hexbin(shots_df, player_name, 'curry_accuracy.png')
create_heatmap_shot_chart(shots_df, player_name, 'curry_heatmap.png')
# Zone analysis
zone_stats = create_zone_analysis(shots_df, player_name)
```
### Advanced: Comparative Shot Charts
```python
def create_comparison_shot_chart(shots_df1, name1, shots_df2, name2):
"""
Compare two players' shot distributions side by side.
"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(20, 11))
# Player 1
draw_court(ax1, color='black', lw=2, outer_lines=True)
hexbin1 = ax1.hexbin(shots_df1['LOC_X'], shots_df1['LOC_Y'],
C=shots_df1['SHOT_MADE_FLAG'],
gridsize=20, reduce_C_function=np.mean,
cmap='RdYlGn', alpha=0.8, vmin=0, vmax=1,
edgecolors='black', linewidths=0.5)
ax1.set_xlim(-250, 250)
ax1.set_ylim(-47.5, 422.5)
ax1.set_aspect('equal')
ax1.axis('off')
ax1.set_title(f'{name1}\nFG%: {shots_df1["SHOT_MADE_FLAG"].mean():.1%}',
fontsize=14, fontweight='bold')
# Player 2
draw_court(ax2, color='black', lw=2, outer_lines=True)
hexbin2 = ax2.hexbin(shots_df2['LOC_X'], shots_df2['LOC_Y'],
C=shots_df2['SHOT_MADE_FLAG'],
gridsize=20, reduce_C_function=np.mean,
cmap='RdYlGn', alpha=0.8, vmin=0, vmax=1,
edgecolors='black', linewidths=0.5)
ax2.set_xlim(-250, 250)
ax2.set_ylim(-47.5, 422.5)
ax2.set_aspect('equal')
ax2.axis('off')
ax2.set_title(f'{name2}\nFG%: {shots_df2["SHOT_MADE_FLAG"].mean():.1%}',
fontsize=14, fontweight='bold')
# Shared colorbar
fig.subplots_adjust(right=0.9)
cbar_ax = fig.add_axes([0.92, 0.15, 0.02, 0.7])
cbar = fig.colorbar(hexbin2, cax=cbar_ax)
cbar.set_label('Field Goal %', fontsize=12, fontweight='bold')
plt.suptitle('Shot Chart Comparison', fontsize=18, fontweight='bold')
return fig
```
## R Implementation
### Using ggplot2 and hoopR
```r
# Load required libraries
library(hoopR)
library(ggplot2)
library(dplyr)
library(tidyr)
library(hexbin)
# Function to draw NBA court
draw_nba_court <- function() {
# Court dimensions
court_theme <- theme_minimal() +
theme(
panel.grid = element_blank(),
axis.text = element_blank(),
axis.title = element_blank(),
axis.ticks = element_blank(),
panel.background = element_rect(fill = '#f0f0f0'),
plot.background = element_rect(fill = 'white'),
legend.position = 'bottom'
)
# Create court lines
court_lines <- list(
# Hoop
geom_circle(aes(x0 = 0, y0 = 0, r = 7.5),
color = 'black', linewidth = 1, inherit.aes = FALSE),
# Backboard
geom_segment(aes(x = -30, y = -7.5, xend = 30, yend = -7.5),
linewidth = 1, color = 'black'),
# Paint (outer)
geom_rect(aes(xmin = -80, xmax = 80, ymin = -47.5, ymax = 142.5),
color = 'black', fill = NA, linewidth = 1),
# Paint (inner)
geom_rect(aes(xmin = -60, xmax = 60, ymin = -47.5, ymax = 142.5),
color = 'black', fill = NA, linewidth = 1),
# Free throw circle (top)
ggforce::geom_arc(aes(x0 = 0, y0 = 142.5, r = 60,
start = 0, end = pi),
color = 'black', linewidth = 1),
# Free throw circle (bottom, dashed)
ggforce::geom_arc(aes(x0 = 0, y0 = 142.5, r = 60,
start = pi, end = 2*pi),
color = 'black', linewidth = 1, linetype = 'dashed'),
# Restricted area
ggforce::geom_arc(aes(x0 = 0, y0 = 0, r = 40,
start = 0, end = pi),
color = 'black', linewidth = 1),
# Three-point line (arc)
ggforce::geom_arc(aes(x0 = 0, y0 = 0, r = 237.5,
start = 0.386, end = 2.756),
color = 'black', linewidth = 1),
# Three-point line (corners)
geom_segment(aes(x = -220, y = -47.5, xend = -220, yend = 92.5),
linewidth = 1, color = 'black'),
geom_segment(aes(x = 220, y = -47.5, xend = 220, yend = 92.5),
linewidth = 1, color = 'black'),
# Outer boundaries
geom_rect(aes(xmin = -250, xmax = 250, ymin = -47.5, ymax = 422.5),
color = 'black', fill = NA, linewidth = 1.5),
# Set limits
coord_fixed(ratio = 1, xlim = c(-250, 250), ylim = c(-47.5, 422.5))
)
return(list(court_theme, court_lines))
}
# Helper function for circle
geom_circle <- function(mapping = NULL, data = NULL, ...) {
ggforce::geom_circle(mapping = mapping, data = data, ...)
}
# Fetch shot chart data
get_nba_shot_data <- function(player_name, season = 2024) {
# Get player info
players <- hoopR::nba_commonallplayers(season = season)
player_info <- players %>%
filter(display_first_last == player_name)
if(nrow(player_info) == 0) {
stop("Player not found")
}
player_id <- player_info$person_id[1]
# Get shot chart data
shots <- hoopR::nba_shotchartdetail(
player_id = player_id,
season = paste0(season - 1, "-", substr(season, 3, 4))
)
return(shots$Shot_Chart_Detail)
}
# 1. Basic Scatter Plot Shot Chart
create_scatter_shot_chart <- function(shots_data, player_name) {
# Prepare data
shots <- shots_data %>%
mutate(
shot_result = ifelse(shot_made_flag == 1, 'Made', 'Missed'),
x = loc_x,
y = loc_y
)
# Calculate statistics
fg_pct <- mean(shots$shot_made_flag) * 100
total_shots <- nrow(shots)
# Create plot
p <- ggplot() +
draw_nba_court() +
geom_point(data = shots,
aes(x = x, y = y, color = shot_result, shape = shot_result),
size = 3, alpha = 0.6) +
scale_color_manual(values = c('Made' = '#00AA00', 'Missed' = '#FF0000')) +
scale_shape_manual(values = c('Made' = 16, 'Missed' = 4)) +
labs(
title = paste(player_name, 'Shot Chart'),
subtitle = sprintf('FG: %d/%d (%.1f%%)',
sum(shots$shot_made_flag), total_shots, fg_pct),
color = 'Result',
shape = 'Result'
) +
theme(
plot.title = element_text(size = 18, face = 'bold', hjust = 0.5),
plot.subtitle = element_text(size = 14, hjust = 0.5),
legend.title = element_text(size = 12, face = 'bold'),
legend.text = element_text(size = 10)
)
return(p)
}
# 2. Hexbin Frequency Shot Chart
create_hexbin_frequency <- function(shots_data, player_name) {
shots <- shots_data %>%
mutate(x = loc_x, y = loc_y)
p <- ggplot(shots, aes(x = x, y = y)) +
draw_nba_court() +
geom_hex(bins = 25, color = 'black', linewidth = 0.2) +
scale_fill_gradient(
low = '#FFFF99',
high = '#CC0000',
name = 'Shot\nAttempts',
guide = guide_colorbar(
barwidth = 15,
barheight = 1,
title.position = 'top',
title.hjust = 0.5
)
) +
labs(
title = paste(player_name, 'Shot Frequency Hexbin'),
subtitle = paste('Total Shots:', nrow(shots))
) +
theme(
plot.title = element_text(size = 18, face = 'bold', hjust = 0.5),
plot.subtitle = element_text(size = 14, hjust = 0.5),
legend.position = 'bottom'
)
return(p)
}
# 3. Shooting Accuracy Hexbin
create_hexbin_accuracy <- function(shots_data, player_name, min_shots = 5) {
shots <- shots_data %>%
mutate(x = loc_x, y = loc_y)
# Create hexbins with custom aggregation
p <- ggplot(shots, aes(x = x, y = y, z = shot_made_flag)) +
draw_nba_court() +
stat_summary_hex(
bins = 20,
fun = function(z) {
if(length(z) >= min_shots) mean(z) else NA
},
color = 'black',
linewidth = 0.2
) +
scale_fill_gradient2(
low = '#FF0000',
mid = '#FFFF00',
high = '#00AA00',
midpoint = 0.5,
limits = c(0, 1),
na.value = '#CCCCCC',
name = 'FG%',
labels = scales::percent_format(),
guide = guide_colorbar(
barwidth = 15,
barheight = 1,
title.position = 'top',
title.hjust = 0.5
)
) +
labs(
title = paste(player_name, 'Shooting Accuracy by Location'),
subtitle = sprintf('(Minimum %d attempts per hexagon)', min_shots)
) +
theme(
plot.title = element_text(size = 18, face = 'bold', hjust = 0.5),
plot.subtitle = element_text(size = 14, hjust = 0.5),
legend.position = 'bottom'
)
return(p)
}
# 4. Density Heat Map
create_density_heatmap <- function(shots_data, player_name) {
shots <- shots_data %>%
mutate(x = loc_x, y = loc_y)
p <- ggplot(shots, aes(x = x, y = y)) +
draw_nba_court() +
stat_density_2d(
aes(fill = after_stat(level)),
geom = 'polygon',
alpha = 0.7,
bins = 20
) +
scale_fill_gradient(
low = '#FFFF99',
high = '#CC0000',
name = 'Shot\nDensity',
guide = guide_colorbar(
barwidth = 15,
barheight = 1,
title.position = 'top',
title.hjust = 0.5
)
) +
labs(
title = paste(player_name, 'Shot Density Heat Map'),
subtitle = paste('Total Shots:', nrow(shots))
) +
theme(
plot.title = element_text(size = 18, face = 'bold', hjust = 0.5),
plot.subtitle = element_text(size = 14, hjust = 0.5),
legend.position = 'bottom'
)
return(p)
}
# 5. Zone-Based Analysis
create_zone_analysis <- function(shots_data, player_name) {
# Assign zones
shots <- shots_data %>%
mutate(
x = loc_x,
y = loc_y,
distance = sqrt(x^2 + y^2),
zone = case_when(
distance <= 40 ~ 'Restricted Area',
abs(x) <= 80 & y <= 142.5 & distance > 40 ~ 'Paint',
distance <= 160 ~ 'Short Mid-Range',
distance <= 237.5 ~ 'Long Mid-Range',
abs(x) >= 220 & y <= 92.5 ~ 'Corner 3',
TRUE ~ 'Above-Break 3'
)
)
# Calculate zone statistics
zone_stats <- shots %>%
group_by(zone) %>%
summarise(
attempts = n(),
makes = sum(shot_made_flag),
fg_pct = mean(shot_made_flag),
.groups = 'drop'
) %>%
mutate(
points_per_shot = fg_pct * ifelse(grepl('3', zone), 3, 2),
frequency = attempts / sum(attempts) * 100
) %>%
arrange(desc(attempts))
# Print table
cat(sprintf("\n%s - Zone Analysis\n", player_name))
cat(strrep("=", 80), "\n")
print(zone_stats, n = Inf)
# Create bar plot
p <- ggplot(zone_stats, aes(x = reorder(zone, -attempts), y = attempts)) +
geom_bar(stat = 'identity', aes(fill = fg_pct), color = 'black') +
geom_text(aes(label = sprintf('%.1f%%', fg_pct * 100)),
vjust = -0.5, size = 4, fontface = 'bold') +
scale_fill_gradient2(
low = '#FF0000',
mid = '#FFFF00',
high = '#00AA00',
midpoint = 0.45,
name = 'FG%',
labels = scales::percent_format()
) +
labs(
title = paste(player_name, 'Zone Analysis'),
subtitle = 'Shot Attempts by Court Zone',
x = 'Zone',
y = 'Attempts'
) +
theme_minimal() +
theme(
plot.title = element_text(size = 18, face = 'bold', hjust = 0.5),
plot.subtitle = element_text(size = 14, hjust = 0.5),
axis.text.x = element_text(angle = 45, hjust = 1, size = 10),
axis.title = element_text(size = 12, face = 'bold'),
legend.position = 'right'
)
return(list(stats = zone_stats, plot = p))
}
# Example usage
if (interactive()) {
# Fetch data
player_name <- 'Stephen Curry'
shots <- get_nba_shot_data(player_name, season = 2024)
# Create visualizations
scatter_plot <- create_scatter_shot_chart(shots, player_name)
hexbin_freq <- create_hexbin_frequency(shots, player_name)
hexbin_acc <- create_hexbin_accuracy(shots, player_name)
density_map <- create_density_heatmap(shots, player_name)
zone_results <- create_zone_analysis(shots, player_name)
# Display plots
print(scatter_plot)
print(hexbin_freq)
print(hexbin_acc)
print(density_map)
print(zone_results$plot)
# Save plots
ggsave('curry_scatter_r.png', scatter_plot, width = 10, height = 11, dpi = 300)
ggsave('curry_hexbin_r.png', hexbin_freq, width = 10, height = 11, dpi = 300)
ggsave('curry_accuracy_r.png', hexbin_acc, width = 10, height = 11, dpi = 300)
ggsave('curry_density_r.png', density_map, width = 10, height = 11, dpi = 300)
ggsave('curry_zones_r.png', zone_results$plot, width = 12, height = 8, dpi = 300)
}
```
## Best Practices
### Data Quality
1. **Minimum Sample Size**: Use at least 100 shots for reliable patterns
2. **Data Validation**: Check for outliers and coordinate errors
3. **Context Inclusion**: Include game situation, defense, time data
4. **Consistent Coordinate System**: Verify court dimensions match NBA standards
### Visualization Design
1. **Color Selection**:
- Use colorblind-friendly palettes
- Red/green for make/miss is standard but not accessible
- Consider blue/orange or other contrasting pairs
2. **Resolution and Binning**:
- Too few bins: Lose spatial detail
- Too many bins: Noisy, unreliable estimates
- Start with 20-25 hexagons for full-season data
3. **Court Orientation**:
- Standard: Basket at bottom, half court at top
- Consider flipping for defensive perspectives
4. **Annotations**:
- Include sample size
- Show league average for comparison
- Mark significant zones or outliers
### Analysis Workflow
1. **Exploration**: Start with scatter plots to see raw data
2. **Aggregation**: Use hexbins to identify patterns
3. **Communication**: Create heat maps for presentations
4. **Insights**: Perform zone analysis for actionable findings
5. **Validation**: Compare with video, league norms, opponent data
## Conclusion
Shot charts are essential tools in modern basketball analytics, providing visual and quantitative insights into shooting performance. Understanding coordinate systems, choosing appropriate visualization techniques, and interpreting patterns correctly are crucial skills for analysts, coaches, and fans.
The choice between scatter plots, hexbins, heat maps, and zone analysis depends on your data size, audience, and analytical goals. Combining multiple approaches often yields the most comprehensive understanding of shooting patterns and player tendencies.
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions