Assignment Description

Paragraph explaining what this assignment is all about.


Step 1. Load the CSV files

Here I explain briefly what files I am loading into memory.

setwd("C:/Users/pptallon/Dropbox/G/Teaching/Loyola College/IS 470 Sports Analytics/Fall 2026/")

source("https://raw.githubusercontent.com/ptallon/SportsAnalytics_Fall2026/refs/heads/main/SharedCode.R")

load_packages(c("data.table", "ggplot2", "plotly", "kableExtra",
                "stringr", "dplyr", "htmltools", "htmlwidgets"))

y1 <- fread("NFLBDB2022/tracking2018.csv")
y2 <- fread("NFLBDB2022/tracking2019.csv")
y3 <- fread("NFLBDB2022/tracking2020.csv")

tracking_df <- rbind(y1, y2, y3)
rm(y1, y2, y3)

# Change the direction of play in tracking_df
tracking_df1 <- tracking_df |>
  mutate(x = ifelse(playDirection == "right", 120-x, x),
         y = ifelse(playDirection == "right", 160/3 - y, y)) |>
  data.frame()

plays   <- fread("NFLBDB2022/plays.csv")
players <- fread("NFLBDB2022/players.csv")
games   <- fread("NFLBDB2022/games.csv")

Step 2. Create a data frame to look at the position of the ball relative to the goal posts

Here I explain how I built up my data frame.

# create a df that follows the path of the ball
perf <- tracking_df1 |>
  select(gameId, playId, x, y, frameId, team, event, time) |>
  left_join(plays |> select(gameId, playId, kickerId, 
                            kickLength, specialTeamsPlayType,
                            specialTeamsResult), 
            by = c("gameId", "playId")) |>
  left_join(players |> select(nflId, displayName),
            by = c("kickerId" = "nflId"))|>
  left_join(games |> select(gameId, season), by = c("gameId") ) |>
  filter(team == "football" & specialTeamsPlayType == "Field Goal" &
         specialTeamsResult %in% c("Kick Attempt Good", 
                                   "Kick Attempt No Good")) |>
  mutate(clean_date = paste(
    str_sub(gameId, 5, 6), # month 
    str_sub(gameId, 7, 8), # day
    str_sub(gameId, 1, 4), # year
    sep = "-"
  )) |>
  
  # group by game and play
  group_by(gameId, playId) |>
  
  # keep only plays where x is negative for any frame
  filter(any(x < 0, na.rm = TRUE)) |>
  
  # keep through the first frame where x becomes negative
  filter(
    !any(x < 0, rm = TRUE) | frameId <= min(frameId[x<0], na.rm=TRUE)
  ) |>
  
  # keep frames that come on or after the field goal attempt event
  filter(frameId >= min(frameId[event == "field_goal_attempt"])) |>
  ungroup() |>
  arrange(gameId, playId, frameId) |>
  mutate(
    dx = x - lag(x),
    dy = y - lag(y),
    angle = -atan(dy/dx) * 180/pi #degrees, a>0 means that ball veers right
  ) |>
  data.frame()

# find the distance from the ball to the nearest goal post
perf_summary <- perf |>
  group_by(gameId, playId) |>
  slice_tail(n=1) |>
  mutate(distance_to_post = min(abs(y-23.58), abs(y-29.75)),
         position = case_when(y < 23.58 ~ "Outside - left of post",
                              y > 29.75 ~ "Outside - right of post",
                              TRUE ~ "Between Posts"),
         missed_by = case_when(y<23.58 ~ round(abs(y-23.58), digits=2),
                               y>29.75 ~ round(abs(y-29.75), digits=2),
                               TRUE ~ NA)) |>
  ungroup() |>
  data.frame()

Step 3. Visualize the location of the ball relative to the goal posts

Let’s take a look at where field goal attempts crossed the goal line

# Make season a factor
perf_summary$season <- factor(perf_summary$season)

g <- ggplot(
  perf_summary,
  aes(
    x = y,
    y = 0,
    color = position,
    group = interaction(season, position),

    # Put season into customdata so JavaScript can use it
    customdata = season,

    text = paste0(
      "Date: ", clean_date, "<br>",
      "Season: ", season, "<br>",
      "Kick Length: ", kickLength, "<br>",
      "Kicker Name: ", displayName,
      ifelse(
        !is.na(missed_by),
        paste0(
          "<br>Missed by: ",
          missed_by
        ),
        "<br>"
      )
    )
  )
) +
  geom_point(
    size = 3,
    alpha = 0.7,
    position = position_jitter(height = 0.1)
  ) +
  geom_vline(
    xintercept = c(23.58, 29.75),
    linetype = "dashed",
    linewidth = 1
  ) +
  labs(
    title = "Final Ball Position Relative to the Goal Posts",
    x = "Final Y Position (yards)",
    y = NULL,
    color = "Position"
  ) +
  theme_minimal() +
  theme(
    axis.text.y = element_blank(),
    axis.ticks.y = element_blank(),
    plot.title = element_text(hjust = 0.5)
  )


# Convert to Plotly
p <- ggplotly(
  g,
  tooltip = "text",
  width = 900,
  height = 600
)

# Add JavaScript that responds to the checkboxes
p <- onRender(
  p,
  "
  function(el, x) {

    // Save the original Plotly data
    var originalData = JSON.parse(JSON.stringify(el.data));

    function filterSeasons() {

      // Determine which seasons are checked
      var selected = [];

      document
        .querySelectorAll('.season-checkbox:checked')
        .forEach(function(box) {
          selected.push(box.value);
        });

      // Go through each Plotly trace
      el.data.forEach(function(trace, i) {

        var original = originalData[i];

        // Only modify traces containing customdata.
        // This leaves the goalpost lines unchanged.
        if (original.customdata) {

          var newX = [];
          var newY = [];
          var newText = [];
          var newCustomdata = [];

          for (var j = 0; j < original.x.length; j++) {

            var season = String(original.customdata[j]);

            if (selected.includes(season)) {

              newX.push(original.x[j]);
              newY.push(original.y[j]);

              if (original.text) {
                newText.push(original.text[j]);
              }

              newCustomdata.push(original.customdata[j]);
            }
          }

          Plotly.restyle(
            el,
            {
              x: [newX],
              y: [newY],
              text: [newText],
              customdata: [newCustomdata]
            },
            [i]
          );
        }
      });
    }

    // Attach the checkbox events
    document
      .querySelectorAll('.season-checkbox')
      .forEach(function(box) {

        box.addEventListener(
          'change',
          filterSeasons
        );

      });

  }
  "
)

div(
  style = "
    width: 900px;
    margin: auto;
  ",

  # Season checkboxes
  div(
    style = "
      margin-bottom: 10px;
      text-align: center;
    ",

    tags$strong("Select Season: "),

    tags$label(
      tags$input(
        type = "checkbox",
        class = "season-checkbox",
        value = "2018",
        checked = "checked"
      ),
      " 2018"
    ),

    HTML("&nbsp;&nbsp;&nbsp;"),

    tags$label(
      tags$input(
        type = "checkbox",
        class = "season-checkbox",
        value = "2019",
        checked = "checked"
      ),
      " 2019"
    ),

    HTML("&nbsp;&nbsp;&nbsp;"),

    tags$label(
      tags$input(
        type = "checkbox",
        class = "season-checkbox",
        value = "2020",
        checked = "checked"
      ),
      " 2020"
    )
  ),

  # Plot
  p
)
Select Season:        

Step 4. Who Missed the Posts? The Ten Worst Offenders!!

perf_summary |>
  filter(!is.na(missed_by)) |>
  select(
    displayName,
    season,
    missed_by,
    position
  ) |>
  arrange(desc(missed_by)) |>
  head(10) |>
  mutate(
    Number = row_number(),
    .before = 1
  ) |>
  kable(
    format = "html",
    col.names = c(
      "#",
      "Kicker",
      "Season",
      "Missed By (yards)",
      "Position"
    ),
    caption = "Field Goal Misses (the ten worst offenders 2018-2020)",
    digits = 2,
    align = c("l", "l", "c", "c", "l")
  ) |>
  kable_styling(
    bootstrap_options = c(
      "striped",
      "hover",
      "condensed"
    ),
    full_width = FALSE,
    position = "center"
  )
Field Goal Misses (the ten worst offenders 2018-2020)
# Kicker Season Missed By (yards) Position
1 Daniel Carlson 2019 9.10 Outside - left of post
2 Stephen Gostkowski 2018 7.73 Outside - right of post
3 Adam Vinatieri 2019 7.32 Outside - left of post
4 Cairo Santos 2019 7.05 Outside - left of post
5 Brett Maher 2019 6.70 Outside - left of post
6 Sam Ficken 2019 6.42 Outside - right of post
7 Tyler Bass 2020 6.09 Outside - right of post
8 Ka’imi Fairbairn 2018 5.95 Outside - right of post
9 Kai Forbath 2020 5.66 Outside - left of post
10 Daniel Carlson 2018 5.61 Outside - right of post

Conclusion

Here are some takeaway conclusions from my project.

Logo