Skip to contents
On this page

A rank says where a team sits, but not how close the teams around it are. This recipe puts one team in the middle of an 11-row table, the five teams ranked just above it and the five just below, so the gap to its neighbours shows next to the number.

It uses three sdvplotR helpers: gt_sdv_logos() for the logos and names, gt_color_ranks() for the rank column and gt_highlight_cells() for the focal row.

The data

cfbfastR’s load_espn_cfb_team_summaries() reads the SportsDataverse season summary release: one row per FBS team, with each metric next to a _rank column.

summaries <- as.data.frame(cfbfastR::load_espn_cfb_team_summaries(2025))
dim(summaries)
#> [1] 136 788
head(grep("_rank$", names(summaries), value = TRUE))
#> [1] "playsgame_off_rank"      "TEPA_off_rank"          
#> [3] "EPAgame_off_rank"        "EPAplay_off_rank"       
#> [5] "EPAdrive_off_rank"       "early_down_EPA_off_rank"

Not every metric has a rank, so check the three this recipe uses. Rank 1 is the best team in each one: the highest net and offensive EPA per play, and the fewest EPA per play allowed on defense.

metrics <- c(
  net_adj_epa = "Net adj. EPA/play",
  EPAplay_off = "Offense EPA/play",
  EPAplay_def = "Defense EPA/play"
)
stopifnot(all(paste0(names(metrics), "_rank") %in% names(summaries)))

The window

Sort by the rank column and take the 11 rows centred on the team. Near the top or bottom of the table there are not five teams on both sides, so the window slides instead of shrinking: it stays 11 rows and the focal team moves off centre.

neighbours <- function(data, team, metric, k = 5) {
  data <- data[order(data[[paste0(metric, "_rank")]]), ]
  i <- match(team, data$pos_team)
  first <- min(max(i - k, 1), nrow(data) - 2 * k)
  rows <- data[first:(first + 2 * k), ]
  data.frame(
    rank = rows[[paste0(metric, "_rank")]],
    team = rows$pos_team,
    value = rows[[metric]]
  )
}

focal <- "Illinois"
neighbours(summaries, focal, "net_adj_epa")
#>    rank          team     value
#> 1    30    Louisville 0.1487155
#> 2    31 South Florida 0.1463640
#> 3    32      Virginia 0.1397002
#> 4    33           TCU 0.1341972
#> 5    34    Iowa State 0.1333091
#> 6    35      Illinois 0.1278535
#> 7    36 East Carolina 0.1276368
#> 8    37     Tennessee 0.1206984
#> 9    38        Toledo 0.1150806
#> 10   39    Pittsburgh 0.1124272
#> 11   40  Old Dominion 0.1124005

The table

The rank colors use the whole league as their domain (1 to the number of teams), not the 11 rows on show, so a mid-table window reads as mid-table. The focal team’s name and value are filled with gt_highlight_cells(), which takes a logical mask the same shape as the columns it fills.

neighbour_table <- function(data, focal_team, metric, label) {
  win <- neighbours(data, focal_team, metric)
  is_focal <- win$team == focal_team
  gt(win) |>
    tab_header(title = label) |>
    gt_sdv_logos(team, league = "cfb", height = 20, include_name = TRUE) |>
    fmt_number(value, decimals = 3) |>
    cols_label(rank = "Rank", team = "Team", value = "Value") |>
    gt_theme_sdv_team(team = focal_team, league = "cfb", density = "compact") |>
    gt_color_ranks(rank, domain = c(1, nrow(data))) |>
    gt_highlight_cells(c(team, value), cbind(is_focal, is_focal), bold = TRUE)
}

neighbour_table(summaries, focal, "net_adj_epa", metrics[["net_adj_epa"]])
Net adj. EPA/play
Rank Team Value
30 Louisville 0.149
31 South Florida 0.146
32 Virginia 0.140
33 TCU 0.134
34 Iowa State 0.133
35 Illinois 0.128
36 East Carolina 0.128
37 Tennessee 0.121
38 Toledo 0.115
39 Pittsburgh 0.112
40 Old Dominion 0.112

Three metrics side by side

The same function, once per metric, laid out with gt_grid().

tables <- lapply(names(metrics), function(m) {
  neighbour_table(summaries, focal, m, metrics[[m]])
})
gt_grid(
  tables,
  ncol = 3,
  gap = 12,
  title = paste(focal, "and its neighbours"),
  subtitle = "2025 FBS season, five teams either side by rank",
  caption = "Data: cfbfastR::load_espn_cfb_team_summaries()"
)
Illinois and its neighbours
2025 FBS season, five teams either side by rank
Net adj. EPA/play
Rank Team Value
30 Louisville 0.149
31 South Florida 0.146
32 Virginia 0.140
33 TCU 0.134
34 Iowa State 0.133
35 Illinois 0.128
36 East Carolina 0.128
37 Tennessee 0.121
38 Toledo 0.115
39 Pittsburgh 0.112
40 Old Dominion 0.112
Offense EPA/play
Rank Team Value
29 Eastern Michigan 0.120
30 SMU 0.119
31 UAB 0.116
32 Army 0.113
33 Ohio 0.111
34 Illinois 0.111
35 Penn State 0.109
36 Temple 0.106
37 Texas 0.104
38 NC State 0.100
39 Utah State 0.093
Defense EPA/play
Rank Team Value
94 Marshall 0.108
95 North Texas 0.110
96 Louisiana 0.111
97 Mississippi State 0.112
98 NC State 0.114
99 Illinois 0.117
100 San José State 0.118
101 Kent State 0.118
102 App State 0.119
103 Texas State 0.119
104 Tennessee 0.121
Data: cfbfastR::load_espn_cfb_team_summaries()

Checking the window

A team more than five places from either end is row 6 of 11. Check it for every team on all three metrics, then look at the rows the top and bottom teams land on.

row_of <- function(team, metric) {
  match(team, neighbours(summaries, team, metric)$team)
}
n <- nrow(summaries)

for (m in names(metrics)) {
  ranks <- summaries[[paste0(m, "_rank")]]
  rows <- vapply(summaries$pos_team, row_of, integer(1), metric = m)
  stopifnot(all(rows[ranks > 5 & ranks <= n - 5] == 6))
}

ranks <- summaries$net_adj_epa_rank
edges <- summaries$pos_team[order(ranks)][c(1:6, (n - 5):n)]
data.frame(
  team = edges,
  rank = ranks[match(edges, summaries$pos_team)],
  row = unname(vapply(edges, row_of, integer(1), metric = "net_adj_epa"))
)
#>             team rank row
#> 1        Indiana    1   1
#> 2     Ohio State    2   2
#> 3     Notre Dame    3   3
#> 4          Miami    4   4
#> 5     Texas Tech    5   5
#> 6         Oregon    6   6
#> 7      Charlotte  131   6
#> 8        Buffalo  132   7
#> 9          Akron  133   8
#> 10    Ball State  134   9
#> 11   Sam Houston  135  10
#> 12 Massachusetts  136  11

Citation

To cite the sdvplotR R package in publications, use:

BibTeX Citation

@misc{gilani_carl_lee_kay_weatherman_sdvplotR,
  author = {Gilani, Saiem and Carl, Sebastian and Lee, Jared and Kay, Camden and Weatherman, Andrew},
  title = {sdvplotR: The SportsDataverse's R Package for Multi-League Sports Plotting.},
  url = {https://sdvplotR.sportsdataverse.org},
  year = {2026}
}