Creating Spray Charts
Intermediate
10 min read
1 views
Nov 26, 2025
Creating Spray Charts: Visualizing Batted Ball Distribution
A spray chart is a visual representation of where a batter hits the ball on the field. Each point represents a batted ball event plotted according to its landing location. Spray charts are fundamental tools in modern baseball analytics for understanding hitting tendencies and defensive positioning.
Uses of Spray Charts
- Player Scouting: Identify hitting tendencies and patterns
- Defensive Positioning: Inform shift strategies and fielder placement
- Pitcher Strategy: Understand how to attack specific batters
- Player Development: Track changes in approach over time
- Matchup Analysis: Compare tendencies against pitch types or handedness
Spray Angle Interpretation
| Spray Angle | Field Location (RHB) | Field Location (LHB) |
|---|---|---|
| -45° to -25° | Deep Pull (LF Line) | Deep Oppo (RF Line) |
| -25° to -10° | Pull (LF) | Opposite (RF) |
| -10° to 10° | Center Field | Center Field |
| 10° to 25° | Opposite (RF) | Pull (LF) |
| 25° to 45° | Deep Oppo (RF Line) | Deep Pull (LF Line) |
Python Implementation
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from pybaseball import statcast_batter, playerid_lookup
# Get player data
def get_spray_data(first_name, last_name, start_date, end_date):
player = playerid_lookup(last_name, first_name)
player_id = player.iloc[0]['key_mlbam']
data = statcast_batter(start_date, end_date, player_id)
batted_balls = data[data['type'] == 'X'].dropna(subset=['hc_x', 'hc_y'])
return batted_balls
# Create spray chart
def create_spray_chart(data, player_name):
fig, ax = plt.subplots(figsize=(12, 10))
# Color by outcome
colors = data['events'].map({
'single': 'green', 'double': 'blue', 'triple': 'purple',
'home_run': 'red', 'field_out': 'gray'
}).fillna('gray')
ax.scatter(data['hc_x'], data['hc_y'], c=colors, alpha=0.6, s=50)
# Draw field elements
# Outfield fence
theta = np.linspace(np.pi/4.5, 3*np.pi/4.5, 150)
fence_x = 125 + 235 * np.cos(theta)
fence_y = 205 - 235 * np.sin(theta)
ax.plot(fence_x, fence_y, 'darkgreen', linewidth=3)
# Foul lines
ax.plot([125, 10], [205, 10], 'white', linewidth=2, linestyle='--')
ax.plot([125, 240], [205, 10], 'white', linewidth=2, linestyle='--')
ax.set_xlim(0, 250)
ax.set_ylim(0, 250)
ax.set_aspect('equal')
ax.set_facecolor('#2E8B57')
ax.set_title(f'{player_name} Spray Chart', fontsize=16, fontweight='bold')
ax.axis('off')
plt.tight_layout()
plt.savefig('spray_chart.png', dpi=300)
plt.show()
# Colored spray chart by outcome
def create_outcome_spray_chart(data, player_name):
fig, ax = plt.subplots(figsize=(14, 12))
outcome_colors = {
'home_run': '#FF0000',
'triple': '#800080',
'double': '#FFA500',
'single': '#00FF00',
'field_out': '#808080'
}
for outcome, color in outcome_colors.items():
subset = data[data['events'] == outcome]
ax.scatter(subset['hc_x'], subset['hc_y'],
c=color, alpha=0.7, s=60, label=outcome.replace('_', ' ').title(),
edgecolors='black', linewidth=0.5)
# Draw field
theta = np.linspace(np.pi/4.5, 3*np.pi/4.5, 150)
fence_x = 125 + 235 * np.cos(theta)
fence_y = 205 - 235 * np.sin(theta)
ax.plot(fence_x, fence_y, 'darkgreen', linewidth=4)
ax.set_xlim(0, 250)
ax.set_ylim(0, 250)
ax.set_aspect('equal')
ax.set_facecolor('#2E8B57')
ax.set_title(f'{player_name} - Spray Chart by Outcome', fontsize=16, fontweight='bold')
ax.legend(loc='upper right', fontsize=10)
ax.axis('off')
plt.tight_layout()
plt.savefig('spray_chart_outcomes.png', dpi=300)
plt.show()
# Exit velocity spray chart
def create_ev_spray_chart(data, player_name):
fig, ax = plt.subplots(figsize=(14, 12))
scatter = ax.scatter(data['hc_x'], data['hc_y'],
c=data['launch_speed'], cmap='plasma',
alpha=0.7, s=60, edgecolors='black', linewidth=0.5)
plt.colorbar(scatter, ax=ax, label='Exit Velocity (mph)')
ax.set_xlim(0, 250)
ax.set_ylim(0, 250)
ax.set_aspect('equal')
ax.set_facecolor('#2E8B57')
ax.set_title(f'{player_name} - Spray Chart by Exit Velocity', fontsize=16)
ax.axis('off')
plt.tight_layout()
plt.savefig('spray_chart_ev.png', dpi=300)
plt.show()
# Example usage
data = get_spray_data("Aaron", "Judge", "2024-04-01", "2024-09-30")
create_spray_chart(data, "Aaron Judge")
create_outcome_spray_chart(data, "Aaron Judge")
R Implementation
library(baseballr)
library(ggplot2)
library(dplyr)
# Get Statcast data
get_spray_data <- function(player_id, start_date, end_date) {
data <- scrape_statcast_savant(
start_date = start_date,
end_date = end_date,
playerid = player_id,
player_type = "batter"
)
batted_balls <- data %>%
filter(type == "X") %>%
filter(!is.na(hc_x) & !is.na(hc_y))
return(batted_balls)
}
# Create spray chart
create_spray_chart <- function(data, player_name) {
# Outfield fence
theta <- seq(pi/4.5, 3*pi/4.5, length.out = 150)
fence <- data.frame(
x = 125 + 235 * cos(theta),
y = 205 - 235 * sin(theta)
)
# Foul lines
foul_left <- data.frame(x = c(125, 10), y = c(205, 10))
foul_right <- data.frame(x = c(125, 240), y = c(205, 10))
p <- ggplot() +
geom_point(data = data, aes(x = hc_x, y = hc_y, color = events),
alpha = 0.7, size = 3) +
scale_color_manual(values = c(
"home_run" = "red", "triple" = "purple", "double" = "orange",
"single" = "green", "field_out" = "gray"
)) +
geom_path(data = fence, aes(x = x, y = y), color = "darkgreen", size = 2) +
geom_path(data = foul_left, aes(x = x, y = y), color = "white", linetype = "dashed") +
geom_path(data = foul_right, aes(x = x, y = y), color = "white", linetype = "dashed") +
coord_fixed() +
xlim(0, 250) +
ylim(0, 250) +
theme_minimal() +
theme(
panel.background = element_rect(fill = "#2E8B57"),
panel.grid = element_blank(),
axis.text = element_blank(),
axis.title = element_blank(),
plot.title = element_text(hjust = 0.5, face = "bold", size = 16)
) +
labs(title = paste(player_name, "- Spray Chart"), color = "Outcome")
ggsave("spray_chart.png", p, width = 12, height = 10, dpi = 300)
print(p)
}
# Exit velocity spray chart
create_ev_spray_chart <- function(data, player_name) {
theta <- seq(pi/4.5, 3*pi/4.5, length.out = 150)
fence <- data.frame(
x = 125 + 235 * cos(theta),
y = 205 - 235 * sin(theta)
)
p <- ggplot() +
geom_point(data = data, aes(x = hc_x, y = hc_y, color = launch_speed),
alpha = 0.7, size = 3) +
scale_color_viridis_c(option = "plasma", name = "Exit Velo (mph)") +
geom_path(data = fence, aes(x = x, y = y), color = "darkgreen", size = 2) +
coord_fixed() +
xlim(0, 250) +
ylim(0, 250) +
theme_minimal() +
theme(
panel.background = element_rect(fill = "#2E8B57"),
panel.grid = element_blank(),
axis.text = element_blank(),
axis.title = element_blank(),
plot.title = element_text(hjust = 0.5, face = "bold", size = 16)
) +
labs(title = paste(player_name, "- Spray Chart by Exit Velocity"))
ggsave("spray_chart_ev.png", p, width = 12, height = 10, dpi = 300)
print(p)
}
# Example: Aaron Judge (player_id: 592450)
# data <- get_spray_data(592450, "2024-04-01", "2024-09-30")
# create_spray_chart(data, "Aaron Judge")
Spray Pattern Analysis
| Pattern Type | Characteristics | Implications |
|---|---|---|
| Pull-Heavy | >50% to pull side | Vulnerable to shifts, power-oriented |
| Balanced | Even distribution | Difficult to shift, consistent contact |
| Opposite Field | >30% to oppo | Compact swing, adjusts to location |
Key Takeaways
- Identify tendencies: Pull vs spray patterns reveal hitting approach
- Color by outcome: Shows where hits vs outs are generated
- Exit velocity overlay: Reveals power zones
- Defensive positioning: Informs shift strategies
- Track changes: Compare across seasons for development analysis
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions