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 AngleField 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 FieldCenter 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 TypeCharacteristicsImplications
Pull-Heavy>50% to pull sideVulnerable to shifts, power-oriented
BalancedEven distributionDifficult to shift, consistent contact
Opposite Field>30% to oppoCompact 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.