3 min read

Football is fundamentally a spatial game. Every play involves 22 players competing for territory on a 100-yard field, with success determined by who controls space more effectively. While traditional statistics count outcomes, spatial analysis...

Chapter 16: Spatial Analysis and Field Visualization

Learning Objectives

By the end of this chapter, you will be able to:

  1. Create accurate football field visualizations with proper dimensions
  2. Plot player positions and movement trajectories
  3. Build route diagrams for passing concepts
  4. Generate heat maps showing spatial tendencies
  5. Visualize formations and personnel alignments
  6. Work with tracking data to animate plays
  7. Create target maps and coverage visualizations
  8. Analyze field zones and territorial control

Introduction

Football is fundamentally a spatial game. Every play involves 22 players competing for territory on a 100-yard field, with success determined by who controls space more effectively. While traditional statistics count outcomes, spatial analysis reveals how those outcomes happen—where players line up, where routes break, where defenders concentrate, and where gaps appear.

This chapter transforms coordinate data into visual insights. We'll build the foundational tools for drawing football fields, plotting player positions, and creating the heat maps, route diagrams, and formation visualizations that professional analytics departments use daily.

The spatial dimension adds context that aggregate statistics cannot capture. A receiver's 12 targets per game means little without knowing whether those targets cluster in the slot or stretch deep downfield. A defense's third-down success rate gains meaning when we see which field zones they dominate and which they surrender.


16.1 The Football Field Canvas

Before plotting any data, we need an accurate representation of the football field itself.

Field Dimensions

A regulation football field has specific dimensions that our visualizations must respect:

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
from typing import Tuple, Optional, List, Dict
from dataclasses import dataclass

@dataclass
class FieldDimensions:
    """Standard football field dimensions in yards."""

    # Field size
    LENGTH: float = 120.0  # Including end zones
    WIDTH: float = 53.33   # 160 feet = 53.33 yards

    # End zones
    ENDZONE_DEPTH: float = 10.0

    # Hash marks (college vs NFL differ)
    COLLEGE_HASH_WIDTH: float = 40.0  # 40 feet from sideline
    NFL_HASH_WIDTH: float = 18.5      # 18.5 feet from center

    # Yard line spacing
    YARD_LINE_SPACING: float = 5.0

    # Goal line positions
    LEFT_GOAL_LINE: float = 10.0
    RIGHT_GOAL_LINE: float = 110.0

    @property
    def field_center_y(self) -> float:
        return self.WIDTH / 2

    @property
    def playing_field_length(self) -> float:
        return 100.0  # Excluding end zones

Drawing the Basic Field

class FootballField:
    """
    Create football field visualizations.

    Supports both college and NFL field configurations.
    """

    def __init__(self, field_type: str = 'college'):
        """
        Initialize field with specified configuration.

        Args:
            field_type: 'college' or 'nfl' for hash mark positions
        """
        self.dims = FieldDimensions()
        self.field_type = field_type

        # Colors
        self.colors = {
            'grass': '#2e5a1c',
            'grass_alt': '#3d7a28',  # For striped effect
            'lines': 'white',
            'endzone': '#264653',
            'numbers': 'white',
            'hash': 'white'
        }

    def draw(self, figsize: Tuple[int, int] = (12, 6),
             show_numbers: bool = True,
             show_hashes: bool = True,
             vertical: bool = False) -> Tuple[plt.Figure, plt.Axes]:
        """
        Draw a football field.

        Args:
            figsize: Figure dimensions
            show_numbers: Whether to show yard numbers
            show_hashes: Whether to show hash marks
            vertical: If True, draw field vertically (sideline at bottom)

        Returns:
            Figure and Axes objects
        """
        if vertical:
            fig, ax = plt.subplots(figsize=(figsize[1], figsize[0]))
        else:
            fig, ax = plt.subplots(figsize=figsize)

        # Field background
        field_rect = patches.Rectangle(
            (0, 0), self.dims.LENGTH, self.dims.WIDTH,
            facecolor=self.colors['grass'],
            edgecolor='none'
        )
        ax.add_patch(field_rect)

        # Add grass stripes (every 5 yards)
        self._draw_grass_stripes(ax)

        # End zones
        self._draw_endzones(ax)

        # Yard lines
        self._draw_yard_lines(ax)

        # Hash marks
        if show_hashes:
            self._draw_hash_marks(ax)

        # Yard numbers
        if show_numbers:
            self._draw_yard_numbers(ax)

        # Goal lines (thicker)
        ax.axvline(self.dims.LEFT_GOAL_LINE, color=self.colors['lines'],
                  linewidth=3)
        ax.axvline(self.dims.RIGHT_GOAL_LINE, color=self.colors['lines'],
                  linewidth=3)

        # Set limits and remove axes
        ax.set_xlim(0, self.dims.LENGTH)
        ax.set_ylim(0, self.dims.WIDTH)
        ax.set_aspect('equal')
        ax.axis('off')

        if vertical:
            # Rotate for vertical orientation
            ax.invert_xaxis()
            ax.set_xlim(self.dims.LENGTH, 0)

        plt.tight_layout()
        return fig, ax

    def _draw_grass_stripes(self, ax: plt.Axes):
        """Draw alternating grass stripes."""
        for yard in range(0, 120, 10):
            if (yard // 10) % 2 == 0:
                stripe = patches.Rectangle(
                    (yard, 0), 10, self.dims.WIDTH,
                    facecolor=self.colors['grass_alt'],
                    edgecolor='none',
                    alpha=0.3
                )
                ax.add_patch(stripe)

    def _draw_endzones(self, ax: plt.Axes):
        """Draw end zones."""
        # Left end zone
        left_ez = patches.Rectangle(
            (0, 0), self.dims.ENDZONE_DEPTH, self.dims.WIDTH,
            facecolor=self.colors['endzone'],
            edgecolor='none'
        )
        ax.add_patch(left_ez)

        # Right end zone
        right_ez = patches.Rectangle(
            (110, 0), self.dims.ENDZONE_DEPTH, self.dims.WIDTH,
            facecolor=self.colors['endzone'],
            edgecolor='none'
        )
        ax.add_patch(right_ez)

    def _draw_yard_lines(self, ax: plt.Axes):
        """Draw yard lines every 5 yards."""
        for yard in range(10, 111, 5):
            linewidth = 2 if yard % 10 == 0 else 1
            ax.axvline(yard, color=self.colors['lines'],
                      linewidth=linewidth, alpha=0.8)

    def _draw_hash_marks(self, ax: plt.Axes):
        """Draw hash marks."""
        if self.field_type == 'college':
            hash_y_bottom = (self.dims.WIDTH - self.dims.COLLEGE_HASH_WIDTH / 3) / 2
            hash_y_top = self.dims.WIDTH - hash_y_bottom
        else:
            hash_y_bottom = self.dims.field_center_y - self.dims.NFL_HASH_WIDTH / 3
            hash_y_top = self.dims.field_center_y + self.dims.NFL_HASH_WIDTH / 3

        # Draw hash marks every yard
        for yard in range(11, 110):
            if yard % 5 != 0:  # Skip yard lines
                ax.plot([yard, yard], [hash_y_bottom - 0.5, hash_y_bottom + 0.5],
                       color=self.colors['hash'], linewidth=0.5)
                ax.plot([yard, yard], [hash_y_top - 0.5, hash_y_top + 0.5],
                       color=self.colors['hash'], linewidth=0.5)

    def _draw_yard_numbers(self, ax: plt.Axes):
        """Draw yard line numbers."""
        number_y_bottom = 5
        number_y_top = self.dims.WIDTH - 5

        for yard in range(10, 100, 10):
            display_num = yard if yard <= 50 else 100 - yard

            # Bottom numbers
            ax.text(yard + 10, number_y_bottom, str(display_num),
                   fontsize=16, color=self.colors['numbers'],
                   ha='center', va='center', fontweight='bold')

            # Top numbers (rotated)
            ax.text(yard + 10, number_y_top, str(display_num),
                   fontsize=16, color=self.colors['numbers'],
                   ha='center', va='center', fontweight='bold',
                   rotation=180)

    def draw_half_field(self, side: str = 'right',
                        figsize: Tuple[int, int] = (8, 8)) -> Tuple[plt.Figure, plt.Axes]:
        """
        Draw half of the field (useful for red zone analysis).

        Args:
            side: 'left' or 'right' half
            figsize: Figure dimensions

        Returns:
            Figure and Axes objects
        """
        fig, ax = self.draw(figsize=figsize, show_numbers=True)

        if side == 'right':
            ax.set_xlim(60, 120)
        else:
            ax.set_xlim(0, 60)

        return fig, ax

Coordinate Systems

Football data comes in various coordinate systems. We need to normalize:

class CoordinateTransformer:
    """
    Transform coordinates between different systems.

    Common systems:
    - Absolute: 0-120 yards (including end zones)
    - Relative: 0-100 yards (playing field only)
    - Offense-oriented: Always attacking toward 100
    """

    def __init__(self):
        self.dims = FieldDimensions()

    def absolute_to_relative(self, x: float) -> float:
        """Convert absolute (0-120) to relative (0-100) yard line."""
        return x - 10

    def relative_to_absolute(self, x: float) -> float:
        """Convert relative (0-100) to absolute (0-120) yard line."""
        return x + 10

    def flip_field(self, x: float, y: float) -> Tuple[float, float]:
        """Flip coordinates so offense always attacks right."""
        new_x = self.dims.LENGTH - x
        new_y = self.dims.WIDTH - y
        return new_x, new_y

    def normalize_to_offense_right(self, x: float, y: float,
                                   offense_going_right: bool) -> Tuple[float, float]:
        """
        Normalize so offense is always attacking toward the right end zone.

        Args:
            x, y: Original coordinates
            offense_going_right: Whether offense is already attacking right

        Returns:
            Normalized (x, y) coordinates
        """
        if offense_going_right:
            return x, y
        else:
            return self.flip_field(x, y)

16.2 Player Position Visualization

With the field drawn, we can plot player positions.

Basic Position Plotting

@dataclass
class PlayerPosition:
    """Single player position on the field."""
    x: float
    y: float
    player_id: str
    team: str  # 'offense', 'defense', 'football'
    position: str = ''
    jersey_number: int = 0


class PlayerPlotter:
    """
    Plot player positions on the field.
    """

    def __init__(self):
        self.colors = {
            'offense': '#e76f51',
            'defense': '#264653',
            'football': '#8B4513',
            'highlight': '#f4a261'
        }

        self.markers = {
            'offense': 'o',
            'defense': 's',
            'football': 'D'
        }

    def plot_positions(self, ax: plt.Axes,
                       players: List[PlayerPosition],
                       show_labels: bool = True,
                       highlight_players: List[str] = None) -> None:
        """
        Plot player positions on field.

        Args:
            ax: Matplotlib axes with field drawn
            players: List of PlayerPosition objects
            show_labels: Whether to show jersey numbers
            highlight_players: Player IDs to highlight
        """
        for player in players:
            is_highlighted = highlight_players and player.player_id in highlight_players

            color = self.colors['highlight'] if is_highlighted else self.colors[player.team]
            size = 200 if is_highlighted else 150

            ax.scatter(
                player.x, player.y,
                s=size,
                c=color,
                marker=self.markers.get(player.team, 'o'),
                edgecolors='white',
                linewidths=2,
                zorder=10
            )

            if show_labels and player.jersey_number > 0:
                ax.annotate(
                    str(player.jersey_number),
                    (player.x, player.y),
                    ha='center', va='center',
                    fontsize=8, color='white',
                    fontweight='bold',
                    zorder=11
                )

    def plot_formation(self, ax: plt.Axes,
                       offensive_positions: List[PlayerPosition],
                       defensive_positions: List[PlayerPosition] = None,
                       line_of_scrimmage: float = None) -> None:
        """
        Plot a full formation with optional line of scrimmage.

        Args:
            ax: Matplotlib axes
            offensive_positions: Offensive player positions
            defensive_positions: Defensive player positions (optional)
            line_of_scrimmage: Yard line for LOS (optional)
        """
        # Line of scrimmage
        if line_of_scrimmage is not None:
            ax.axvline(line_of_scrimmage, color='blue',
                      linewidth=2, linestyle='--', alpha=0.7)

        # Plot offense
        self.plot_positions(ax, offensive_positions)

        # Plot defense
        if defensive_positions:
            self.plot_positions(ax, defensive_positions)

Movement Trails

class MovementVisualizer:
    """
    Visualize player movement over time.
    """

    def __init__(self):
        self.colors = {
            'offense': '#e76f51',
            'defense': '#264653'
        }

    def plot_trajectory(self, ax: plt.Axes,
                        positions: List[Tuple[float, float]],
                        team: str = 'offense',
                        fade: bool = True,
                        arrow: bool = True) -> None:
        """
        Plot player movement trajectory.

        Args:
            ax: Matplotlib axes
            positions: List of (x, y) coordinates over time
            team: 'offense' or 'defense' for coloring
            fade: Whether to fade earlier positions
            arrow: Whether to show direction arrow
        """
        if len(positions) < 2:
            return

        xs, ys = zip(*positions)
        color = self.colors[team]

        if fade:
            # Gradient from transparent to solid
            for i in range(len(positions) - 1):
                alpha = (i + 1) / len(positions)
                ax.plot(
                    [xs[i], xs[i+1]], [ys[i], ys[i+1]],
                    color=color, alpha=alpha, linewidth=2
                )
        else:
            ax.plot(xs, ys, color=color, linewidth=2)

        # Direction arrow at end
        if arrow and len(positions) >= 2:
            dx = xs[-1] - xs[-2]
            dy = ys[-1] - ys[-2]
            ax.annotate(
                '', xy=(xs[-1], ys[-1]),
                xytext=(xs[-2], ys[-2]),
                arrowprops=dict(arrowstyle='->', color=color, lw=2)
            )

        # Current position marker
        ax.scatter(xs[-1], ys[-1], s=100, c=color,
                  edgecolors='white', linewidths=2, zorder=10)

    def plot_all_trajectories(self, ax: plt.Axes,
                              all_positions: Dict[str, List[Tuple[float, float]]],
                              teams: Dict[str, str]) -> None:
        """
        Plot trajectories for all players.

        Args:
            ax: Matplotlib axes
            all_positions: Dict mapping player_id to list of positions
            teams: Dict mapping player_id to team
        """
        for player_id, positions in all_positions.items():
            team = teams.get(player_id, 'offense')
            self.plot_trajectory(ax, positions, team=team)

16.3 Route Diagrams

Passing routes are fundamental to offensive design. Visualizing them helps understand concepts and tendencies.

Route Drawing

class RouteVisualizer:
    """
    Create route diagrams for passing plays.
    """

    def __init__(self):
        self.route_colors = {
            'go': '#e76f51',
            'out': '#2a9d8f',
            'in': '#e9c46a',
            'slant': '#f4a261',
            'curl': '#264653',
            'comeback': '#7209b7',
            'post': '#3a0ca3',
            'corner': '#4361ee',
            'flat': '#80b918',
            'screen': '#aacc00',
            'default': '#666666'
        }

    def draw_route(self, ax: plt.Axes,
                   start: Tuple[float, float],
                   waypoints: List[Tuple[float, float]],
                   route_type: str = 'default',
                   show_break: bool = True) -> None:
        """
        Draw a single route.

        Args:
            ax: Matplotlib axes
            start: Starting (x, y) position
            waypoints: List of (x, y) waypoints defining the route
            route_type: Type of route for coloring
            show_break: Whether to mark the break point
        """
        color = self.route_colors.get(route_type.lower(), self.route_colors['default'])

        # Full path
        all_points = [start] + waypoints
        xs, ys = zip(*all_points)

        # Draw route line
        ax.plot(xs, ys, color=color, linewidth=2.5, solid_capstyle='round')

        # Starting position
        ax.scatter(start[0], start[1], s=150, c=color,
                  edgecolors='white', linewidths=2, zorder=10)

        # Break point marker
        if show_break and len(waypoints) >= 2:
            break_point = waypoints[0]
            ax.scatter(break_point[0], break_point[1], s=50, c='white',
                      edgecolors=color, linewidths=2, zorder=11)

        # End arrow
        if len(waypoints) >= 1:
            end = waypoints[-1]
            if len(waypoints) >= 2:
                prev = waypoints[-2]
            else:
                prev = start

            dx = end[0] - prev[0]
            dy = end[1] - prev[1]

            # Normalize and scale arrow
            length = np.sqrt(dx**2 + dy**2)
            if length > 0:
                dx, dy = dx/length * 2, dy/length * 2

            ax.annotate(
                '', xy=(end[0], end[1]),
                xytext=(end[0] - dx, end[1] - dy),
                arrowprops=dict(arrowstyle='->', color=color, lw=2.5)
            )

    def draw_passing_concept(self, ax: plt.Axes,
                             routes: Dict[str, Dict],
                             los: float,
                             title: str = None) -> None:
        """
        Draw a complete passing concept with multiple routes.

        Args:
            ax: Matplotlib axes
            routes: Dict of routes with 'start', 'waypoints', 'type' keys
            los: Line of scrimmage
            title: Optional concept name
        """
        # Draw LOS
        ax.axvline(los, color='blue', linewidth=2, linestyle='--', alpha=0.5)

        # Draw each route
        for route_name, route_info in routes.items():
            self.draw_route(
                ax,
                start=route_info['start'],
                waypoints=route_info['waypoints'],
                route_type=route_info.get('type', 'default')
            )

            # Label the route
            end = route_info['waypoints'][-1]
            ax.text(end[0] + 1, end[1], route_name,
                   fontsize=9, color='#264653')

        if title:
            ax.set_title(title, fontsize=14, fontweight='bold')


def create_common_routes() -> Dict[str, Dict]:
    """
    Create sample route tree from typical formations.
    """
    # Routes from 2x2 formation, ball at 70 yard line
    los = 70

    routes = {
        'X - Post': {
            'start': (los - 1, 5),
            'waypoints': [(los + 10, 5), (los + 20, 20)],
            'type': 'post'
        },
        'H - Flat': {
            'start': (los - 2, 20),
            'waypoints': [(los + 5, 15)],
            'type': 'flat'
        },
        'Y - Curl': {
            'start': (los - 1, 35),
            'waypoints': [(los + 12, 35), (los + 10, 35)],
            'type': 'curl'
        },
        'Z - Corner': {
            'start': (los - 1, 48),
            'waypoints': [(los + 10, 48), (los + 20, 53)],
            'type': 'corner'
        }
    }

    return routes

16.4 Heat Maps and Density Plots

Heat maps reveal spatial tendencies across many plays.

Target Heat Maps

from scipy import stats

class SpatialHeatMap:
    """
    Create heat maps showing spatial tendencies.
    """

    def __init__(self):
        self.colormaps = {
            'targets': 'YlOrRd',
            'completions': 'Greens',
            'epa': 'RdYlGn',
            'tackles': 'Blues'
        }

    def create_target_heatmap(self, ax: plt.Axes,
                               target_locations: List[Tuple[float, float]],
                               cmap: str = 'YlOrRd',
                               levels: int = 20) -> None:
        """
        Create heat map of target locations.

        Args:
            ax: Matplotlib axes (should have field drawn)
            target_locations: List of (x, y) target coordinates
            cmap: Colormap name
            levels: Number of contour levels
        """
        if len(target_locations) < 3:
            return

        xs, ys = zip(*target_locations)
        xs, ys = np.array(xs), np.array(ys)

        # Create kernel density estimate
        xmin, xmax = 0, 120
        ymin, ymax = 0, 53.33

        xx, yy = np.mgrid[xmin:xmax:100j, ymin:ymax:50j]
        positions = np.vstack([xx.ravel(), yy.ravel()])
        values = np.vstack([xs, ys])

        try:
            kernel = stats.gaussian_kde(values)
            density = np.reshape(kernel(positions).T, xx.shape)

            # Plot contours
            ax.contourf(xx, yy, density, levels=levels, cmap=cmap, alpha=0.6)
        except:
            # Fall back to scatter if KDE fails
            ax.scatter(xs, ys, alpha=0.5, s=30)

    def create_zone_heatmap(self, ax: plt.Axes,
                             zone_values: Dict[str, float],
                             cmap: str = 'RdYlGn') -> None:
        """
        Create heat map using predefined field zones.

        Args:
            ax: Matplotlib axes
            zone_values: Dict mapping zone names to values
            cmap: Colormap name
        """
        # Define zones (simplified grid)
        zones = {
            'deep_left': {'x': (10, 60), 'y': (0, 17.78)},
            'deep_middle': {'x': (10, 60), 'y': (17.78, 35.55)},
            'deep_right': {'x': (10, 60), 'y': (35.55, 53.33)},
            'short_left': {'x': (60, 90), 'y': (0, 17.78)},
            'short_middle': {'x': (60, 90), 'y': (17.78, 35.55)},
            'short_right': {'x': (60, 90), 'y': (35.55, 53.33)},
            'red_zone_left': {'x': (90, 110), 'y': (0, 17.78)},
            'red_zone_middle': {'x': (90, 110), 'y': (17.78, 35.55)},
            'red_zone_right': {'x': (90, 110), 'y': (35.55, 53.33)}
        }

        # Get colormap
        cm = plt.cm.get_cmap(cmap)

        # Normalize values
        values = list(zone_values.values())
        vmin, vmax = min(values), max(values)

        for zone_name, zone_bounds in zones.items():
            if zone_name in zone_values:
                value = zone_values[zone_name]
                norm_value = (value - vmin) / (vmax - vmin) if vmax > vmin else 0.5

                rect = patches.Rectangle(
                    (zone_bounds['x'][0], zone_bounds['y'][0]),
                    zone_bounds['x'][1] - zone_bounds['x'][0],
                    zone_bounds['y'][1] - zone_bounds['y'][0],
                    facecolor=cm(norm_value),
                    edgecolor='white',
                    linewidth=1,
                    alpha=0.7
                )
                ax.add_patch(rect)

                # Add value label
                center_x = (zone_bounds['x'][0] + zone_bounds['x'][1]) / 2
                center_y = (zone_bounds['y'][0] + zone_bounds['y'][1]) / 2
                ax.text(center_x, center_y, f'{value:.1f}',
                       ha='center', va='center', fontsize=10,
                       fontweight='bold', color='black')

Defensive Coverage Maps

class CoverageVisualizer:
    """
    Visualize defensive coverage tendencies.
    """

    def __init__(self):
        self.zone_colors = {
            'deep_third_left': '#3a0ca3',
            'deep_third_middle': '#4361ee',
            'deep_third_right': '#4cc9f0',
            'hook_curl_left': '#7209b7',
            'hook_curl_right': '#b5179e',
            'flat_left': '#f72585',
            'flat_right': '#ff6b6b'
        }

    def draw_cover_3_zones(self, ax: plt.Axes, los: float = 70) -> None:
        """
        Draw Cover 3 zone responsibilities.

        Args:
            ax: Matplotlib axes
            los: Line of scrimmage
        """
        width = 53.33
        third = width / 3

        # Deep thirds
        for i, name in enumerate(['left', 'middle', 'right']):
            zone = patches.Rectangle(
                (los + 15, i * third),
                35, third,
                facecolor=self.zone_colors[f'deep_third_{name}'],
                edgecolor='white',
                linewidth=2,
                alpha=0.4
            )
            ax.add_patch(zone)
            ax.text(los + 32, i * third + third/2, f'Deep\n1/3',
                   ha='center', va='center', fontsize=9, color='white')

        # Hook/curl zones
        hook_curl_left = patches.Rectangle(
            (los + 5, 0), 10, width/2,
            facecolor=self.zone_colors['hook_curl_left'],
            edgecolor='white',
            linewidth=2,
            alpha=0.4
        )
        ax.add_patch(hook_curl_left)

        hook_curl_right = patches.Rectangle(
            (los + 5, width/2), 10, width/2,
            facecolor=self.zone_colors['hook_curl_right'],
            edgecolor='white',
            linewidth=2,
            alpha=0.4
        )
        ax.add_patch(hook_curl_right)

        # Flats
        flat_left = patches.Rectangle(
            (los, 0), 8, 10,
            facecolor=self.zone_colors['flat_left'],
            edgecolor='white',
            linewidth=2,
            alpha=0.4
        )
        ax.add_patch(flat_left)

        flat_right = patches.Rectangle(
            (los, width - 10), 8, 10,
            facecolor=self.zone_colors['flat_right'],
            edgecolor='white',
            linewidth=2,
            alpha=0.4
        )
        ax.add_patch(flat_right)

16.5 Formation Analysis

Formations reveal offensive and defensive philosophy.

Formation Visualization

@dataclass
class FormationTemplate:
    """Template for common formations."""
    name: str
    positions: Dict[str, Tuple[float, float]]  # Position name to (x_offset, y)


class FormationVisualizer:
    """
    Visualize offensive and defensive formations.
    """

    def __init__(self):
        self.offense_templates = self._create_offense_templates()
        self.defense_templates = self._create_defense_templates()

    def _create_offense_templates(self) -> Dict[str, FormationTemplate]:
        """Create common offensive formation templates."""
        field_center = 26.67

        return {
            'shotgun_2x2': FormationTemplate(
                name='Shotgun 2x2',
                positions={
                    'QB': (-5, field_center),
                    'RB': (-6, field_center - 3),
                    'LT': (0, field_center - 8),
                    'LG': (0, field_center - 4),
                    'C': (0, field_center),
                    'RG': (0, field_center + 4),
                    'RT': (0, field_center + 8),
                    'X': (0, 5),
                    'H': (-1, field_center - 12),
                    'Y': (-1, field_center + 12),
                    'Z': (0, 48)
                }
            ),
            'i_formation': FormationTemplate(
                name='I-Formation',
                positions={
                    'QB': (-2, field_center),
                    'FB': (-5, field_center),
                    'TB': (-8, field_center),
                    'LT': (0, field_center - 8),
                    'LG': (0, field_center - 4),
                    'C': (0, field_center),
                    'RG': (0, field_center + 4),
                    'RT': (0, field_center + 8),
                    'TE': (0, field_center + 10),
                    'X': (0, 5),
                    'Z': (0, 48)
                }
            ),
            'empty': FormationTemplate(
                name='Empty',
                positions={
                    'QB': (-5, field_center),
                    'LT': (0, field_center - 8),
                    'LG': (0, field_center - 4),
                    'C': (0, field_center),
                    'RG': (0, field_center + 4),
                    'RT': (0, field_center + 8),
                    'X': (0, 5),
                    'H': (-1, field_center - 12),
                    'F': (-1, field_center + 6),
                    'Y': (-1, field_center + 16),
                    'Z': (0, 48)
                }
            )
        }

    def _create_defense_templates(self) -> Dict[str, FormationTemplate]:
        """Create common defensive formation templates."""
        field_center = 26.67

        return {
            '4-3': FormationTemplate(
                name='4-3 Defense',
                positions={
                    'DE_L': (2, field_center - 12),
                    'DT_L': (2, field_center - 4),
                    'DT_R': (2, field_center + 4),
                    'DE_R': (2, field_center + 12),
                    'WLB': (5, field_center - 10),
                    'MLB': (5, field_center),
                    'SLB': (5, field_center + 10),
                    'CB_L': (7, 5),
                    'SS': (10, field_center - 8),
                    'FS': (15, field_center),
                    'CB_R': (7, 48)
                }
            ),
            'nickel': FormationTemplate(
                name='Nickel Defense',
                positions={
                    'DE_L': (2, field_center - 10),
                    'DT_L': (2, field_center - 3),
                    'DT_R': (2, field_center + 3),
                    'DE_R': (2, field_center + 10),
                    'LB_L': (5, field_center - 6),
                    'LB_R': (5, field_center + 6),
                    'CB_L': (7, 5),
                    'SLOT': (5, field_center - 14),
                    'SS': (10, field_center + 8),
                    'FS': (15, field_center),
                    'CB_R': (7, 48)
                }
            )
        }

    def draw_formation(self, ax: plt.Axes,
                       template: FormationTemplate,
                       los: float,
                       is_offense: bool = True,
                       show_labels: bool = True) -> None:
        """
        Draw a formation from template.

        Args:
            ax: Matplotlib axes
            template: Formation template
            los: Line of scrimmage
            is_offense: True for offense (positions behind LOS)
            show_labels: Whether to show position labels
        """
        color = '#e76f51' if is_offense else '#264653'
        multiplier = -1 if is_offense else 1

        for pos_name, (x_offset, y) in template.positions.items():
            x = los + (x_offset * multiplier)

            ax.scatter(x, y, s=200, c=color,
                      edgecolors='white', linewidths=2, zorder=10)

            if show_labels:
                ax.text(x, y, pos_name[:2],
                       ha='center', va='center',
                       fontsize=7, color='white',
                       fontweight='bold', zorder=11)

    def draw_matchup(self, ax: plt.Axes,
                     offense_name: str,
                     defense_name: str,
                     los: float) -> None:
        """
        Draw offense vs defense matchup.

        Args:
            ax: Matplotlib axes
            offense_name: Name of offensive formation
            defense_name: Name of defensive formation
            los: Line of scrimmage
        """
        if offense_name in self.offense_templates:
            self.draw_formation(ax, self.offense_templates[offense_name],
                               los, is_offense=True)

        if defense_name in self.defense_templates:
            self.draw_formation(ax, self.defense_templates[defense_name],
                               los, is_offense=False)

        ax.axvline(los, color='blue', linewidth=2, linestyle='--')

16.6 Tracking Data Visualization

Modern tracking data provides player positions at high frequency (10+ times per second).

Tracking Data Animation

from matplotlib.animation import FuncAnimation
from IPython.display import HTML

class TrackingAnimator:
    """
    Create animations from tracking data.
    """

    def __init__(self):
        self.field = FootballField()
        self.colors = {
            'offense': '#e76f51',
            'defense': '#264653',
            'football': '#8B4513'
        }

    def create_play_animation(self,
                               tracking_data: pd.DataFrame,
                               fps: int = 10) -> FuncAnimation:
        """
        Create animation of a single play.

        Args:
            tracking_data: DataFrame with columns:
                - frame_id: Frame number
                - player_id: Player identifier
                - x, y: Position coordinates
                - team: 'offense', 'defense', or 'football'
            fps: Frames per second

        Returns:
            FuncAnimation object
        """
        fig, ax = self.field.draw(figsize=(12, 6))

        # Get unique frames
        frames = sorted(tracking_data['frame_id'].unique())

        # Initialize scatter plots
        offense_scatter = ax.scatter([], [], s=150, c=self.colors['offense'],
                                     edgecolors='white', linewidths=2, zorder=10)
        defense_scatter = ax.scatter([], [], s=150, c=self.colors['defense'],
                                     edgecolors='white', linewidths=2, zorder=10)
        ball_scatter = ax.scatter([], [], s=100, c=self.colors['football'],
                                  edgecolors='white', linewidths=2, zorder=11)

        def init():
            offense_scatter.set_offsets(np.empty((0, 2)))
            defense_scatter.set_offsets(np.empty((0, 2)))
            ball_scatter.set_offsets(np.empty((0, 2)))
            return offense_scatter, defense_scatter, ball_scatter

        def update(frame_id):
            frame_data = tracking_data[tracking_data['frame_id'] == frame_id]

            # Update offense
            offense_data = frame_data[frame_data['team'] == 'offense']
            if len(offense_data) > 0:
                offense_scatter.set_offsets(offense_data[['x', 'y']].values)

            # Update defense
            defense_data = frame_data[frame_data['team'] == 'defense']
            if len(defense_data) > 0:
                defense_scatter.set_offsets(defense_data[['x', 'y']].values)

            # Update ball
            ball_data = frame_data[frame_data['team'] == 'football']
            if len(ball_data) > 0:
                ball_scatter.set_offsets(ball_data[['x', 'y']].values)

            return offense_scatter, defense_scatter, ball_scatter

        anim = FuncAnimation(
            fig, update, frames=frames,
            init_func=init, blit=True,
            interval=1000/fps
        )

        return anim

    def save_animation(self, anim: FuncAnimation,
                       filename: str,
                       fps: int = 10) -> None:
        """Save animation to file."""
        anim.save(filename, writer='pillow', fps=fps)

Tracking Data Analysis

class TrackingAnalyzer:
    """
    Analyze tracking data for spatial insights.
    """

    def calculate_speed(self, positions: List[Tuple[float, float]],
                        time_interval: float = 0.1) -> List[float]:
        """
        Calculate speed from position data.

        Args:
            positions: List of (x, y) positions
            time_interval: Time between frames in seconds

        Returns:
            List of speeds in yards per second
        """
        speeds = [0.0]

        for i in range(1, len(positions)):
            dx = positions[i][0] - positions[i-1][0]
            dy = positions[i][1] - positions[i-1][1]
            distance = np.sqrt(dx**2 + dy**2)
            speed = distance / time_interval
            speeds.append(speed)

        return speeds

    def calculate_separation(self,
                             receiver_pos: Tuple[float, float],
                             defender_positions: List[Tuple[float, float]]) -> float:
        """
        Calculate receiver separation from nearest defender.

        Args:
            receiver_pos: (x, y) of receiver
            defender_positions: List of (x, y) for defenders

        Returns:
            Distance to nearest defender in yards
        """
        if not defender_positions:
            return float('inf')

        distances = []
        for def_pos in defender_positions:
            dx = receiver_pos[0] - def_pos[0]
            dy = receiver_pos[1] - def_pos[1]
            distances.append(np.sqrt(dx**2 + dy**2))

        return min(distances)

    def calculate_space_created(self,
                                 positions: List[Tuple[float, float]],
                                 grid_size: float = 5.0) -> float:
        """
        Calculate area of space controlled by offense.

        Uses convex hull of offensive player positions.
        """
        from scipy.spatial import ConvexHull

        if len(positions) < 3:
            return 0.0

        try:
            points = np.array(positions)
            hull = ConvexHull(points)
            return hull.volume  # Area in 2D
        except:
            return 0.0

16.7 Field Zone Analysis

Dividing the field into zones enables aggregate spatial analysis.

Zone Definitions

class FieldZones:
    """
    Define and analyze field zones.
    """

    def __init__(self):
        self.width = 53.33

        # Define zones
        self.zones = {
            # Horizontal zones (by depth)
            'behind_los': {'x': (0, 0), 'description': 'Behind line of scrimmage'},
            'short': {'x': (0, 10), 'description': '0-10 yards'},
            'intermediate': {'x': (10, 20), 'description': '10-20 yards'},
            'deep': {'x': (20, 100), 'description': '20+ yards'},

            # Vertical zones (by field width)
            'left_sideline': {'y': (0, 10)},
            'left_hash': {'y': (10, 20)},
            'middle': {'y': (20, 33.33)},
            'right_hash': {'y': (33.33, 43.33)},
            'right_sideline': {'y': (43.33, 53.33)}
        }

    def get_zone(self, x: float, y: float,
                 los: float) -> Dict[str, str]:
        """
        Determine which zones a point falls into.

        Args:
            x, y: Coordinates
            los: Line of scrimmage

        Returns:
            Dict with 'depth_zone' and 'width_zone'
        """
        # Depth relative to LOS
        depth = x - los

        if depth < 0:
            depth_zone = 'behind_los'
        elif depth < 10:
            depth_zone = 'short'
        elif depth < 20:
            depth_zone = 'intermediate'
        else:
            depth_zone = 'deep'

        # Width zone
        if y < 10:
            width_zone = 'left_sideline'
        elif y < 20:
            width_zone = 'left_hash'
        elif y < 33.33:
            width_zone = 'middle'
        elif y < 43.33:
            width_zone = 'right_hash'
        else:
            width_zone = 'right_sideline'

        return {'depth_zone': depth_zone, 'width_zone': width_zone}

    def create_zone_summary(self, plays: pd.DataFrame,
                            value_col: str) -> pd.DataFrame:
        """
        Summarize a metric by zone.

        Args:
            plays: DataFrame with 'target_x', 'target_y', 'los', and value_col
            value_col: Column to aggregate

        Returns:
            DataFrame with zone summaries
        """
        results = []

        for _, play in plays.iterrows():
            zones = self.get_zone(play['target_x'], play['target_y'], play['los'])
            results.append({
                'depth_zone': zones['depth_zone'],
                'width_zone': zones['width_zone'],
                'value': play[value_col]
            })

        results_df = pd.DataFrame(results)

        return results_df.groupby(['depth_zone', 'width_zone']).agg({
            'value': ['mean', 'count', 'std']
        }).round(3)

16.8 Integrated Spatial Dashboard

Combining all spatial visualizations:

def create_spatial_dashboard(team_name: str,
                              target_data: pd.DataFrame,
                              formation_counts: Dict[str, int]) -> plt.Figure:
    """
    Create comprehensive spatial analysis dashboard.

    Args:
        team_name: Team being analyzed
        target_data: DataFrame with target locations
        formation_counts: Dict of formation frequencies

    Returns:
        Figure with multiple spatial visualizations
    """
    fig = plt.figure(figsize=(16, 12))

    # Create grid
    gs = fig.add_gridspec(2, 3, hspace=0.3, wspace=0.2)

    # Panel 1: Target heat map (top left, spans 2 columns)
    ax1 = fig.add_subplot(gs[0, :2])
    field = FootballField()
    field.draw_on_axes(ax1)

    heatmap = SpatialHeatMap()
    target_locations = list(zip(target_data['x'], target_data['y']))
    heatmap.create_target_heatmap(ax1, target_locations)
    ax1.set_title(f'{team_name} Target Locations', fontsize=12, fontweight='bold')

    # Panel 2: Formation usage (top right)
    ax2 = fig.add_subplot(gs[0, 2])
    formations = list(formation_counts.keys())
    counts = list(formation_counts.values())
    ax2.barh(formations, counts, color='#264653')
    ax2.set_title('Formation Usage', fontsize=12, fontweight='bold')
    ax2.set_xlabel('Plays')

    # Panel 3: Zone efficiency (bottom left)
    ax3 = fig.add_subplot(gs[1, 0])
    # Sample zone values
    zone_values = {
        'deep_left': 0.35,
        'deep_middle': 0.28,
        'deep_right': 0.42,
        'short_left': 0.55,
        'short_middle': 0.48,
        'short_right': 0.52
    }
    # Would draw zone heatmap here

    # Panel 4: Route tree (bottom middle)
    ax4 = fig.add_subplot(gs[1, 1])
    # Would draw common routes here

    # Panel 5: Key stats (bottom right)
    ax5 = fig.add_subplot(gs[1, 2])
    ax5.axis('off')

    stats_text = f"""
    SPATIAL SUMMARY

    Total Targets: {len(target_data)}
    Avg Depth: {target_data['x'].mean():.1f} yards

    Zone Breakdown:
    - Short: {sum(target_data['x'] < 10) / len(target_data):.0%}
    - Intermediate: {sum((target_data['x'] >= 10) & (target_data['x'] < 20)) / len(target_data):.0%}
    - Deep: {sum(target_data['x'] >= 20) / len(target_data):.0%}
    """
    ax5.text(0.1, 0.9, stats_text, transform=ax5.transAxes,
            fontsize=10, verticalalignment='top', fontfamily='monospace')

    fig.suptitle(f'{team_name} Spatial Analysis', fontsize=16, fontweight='bold')

    return fig

Chapter Summary

Spatial analysis reveals patterns invisible in aggregate statistics:

  • Field visualization: Accurate representation is the foundation
  • Position plotting: Player locations at the snap and throughout plays
  • Route diagrams: Visual language for passing concepts
  • Heat maps: Density analysis across many plays
  • Formation analysis: Structural tendencies on both sides
  • Tracking data: High-frequency movement analysis
  • Zone analysis: Aggregate spatial performance

The spatial dimension adds context that transforms numbers into football insight.


Key Terms

  • Convex hull: Smallest polygon enclosing a set of points
  • Heat map: Density visualization showing concentration of events
  • Kernel density estimation: Statistical method for estimating probability density
  • Line of scrimmage (LOS): Starting point for each play
  • Tracking data: High-frequency position data (typically 10 Hz)
  • Zone: Predefined region of the field for analysis

Practice Exercises

  1. Draw a football field with accurate dimensions and yard numbers
  2. Plot a play with 11 offensive and 11 defensive players
  3. Create a heat map of target locations for a receiver
  4. Animate a play using tracking data
  5. Build a formation comparison visualization

Further Reading

  • NFL Big Data Bowl documentation
  • mplsoccer library for sports field visualization
  • Scipy spatial analysis documentation