Paragraph explaining what this assignment is all about.
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")
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()
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(" "),
tags$label(
tags$input(
type = "checkbox",
class = "season-checkbox",
value = "2019",
checked = "checked"
),
" 2019"
),
HTML(" "),
tags$label(
tags$input(
type = "checkbox",
class = "season-checkbox",
value = "2020",
checked = "checked"
),
" 2020"
)
),
# Plot
p
)
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"
)
| # | 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 |
Here are some takeaway conclusions from my project.