
Winningest Franchises in Rolling Windows
Saiem Gilani
Source: vignettes/window_wins.Rmd
window_wins.RmdOn this page
This article builds one table: the NFL’s five winningest franchises
in every ten-year window from 1999-2008 to 2016-2025, each franchise
drawn as its logo and shaded by how many Super Bowls it won inside that
window. The games are nflverse’s schedule file, read with nflreadr, regular
season only.
Eighteen windows give a table six columns wide and eighteen rows
tall, which exports as a narrow strip with empty space on either side.
gt_snake() folds it into two side-by-side blocks that
repeat the column labels. The shading is per-cell, and it is far easier
to reason about on the plain eighteen-row grid than on the folded one,
so we color first and fold second. Along the way the article uses
gt_highlight_cells(), gt_legend_discrete() and
gt_538_caption().
gt_snake() and the other gt_* table
functions here come from Andrew Weatherman’s gtUtils; this
article follows his rolling-window example, rebuilt on nflverse
data.
1. One row per team per game
load_schedules() holds one row per game.
clean_homeaway() doubles it into one row per team per game,
renaming the home_* / away_* pairs to
team_* / opponent_*.
seasons <- 1999:2025
schedules <- nflreadr::load_schedules(seasons)
games <- schedules |>
filter(game_type == "REG", !is.na(home_score)) |>
select(season, home_team, away_team, home_score, away_score) |>
nflreadr::clean_homeaway() |>
mutate(franchise = sdv_resolve(team, league = "nfl"))The schedules keep each season’s abbreviation, so the Raiders are
OAK before 2020 and LV after.
sdv_resolve() maps every relocated abbreviation to today’s
franchise key, which is what lets one franchise’s ten seasons add
up.
sdv_resolve(c("OAK", "SD", "STL"), league = "nfl")
#> [1] "13" "24" "14"Each franchise’s record per season is then one summary:
2. Rolling windows
A rolling window is a filter and a sum. This function keeps the ten seasons from a start year and totals each franchise’s record.
calculate_windows <- function(start_year, data) {
data |>
filter(between(season, start_year, start_year + 9)) |>
summarise(across(c(wins, losses, ties), sum), .by = franchise) |>
mutate(
win_pct = (wins + ties / 2) / (wins + losses + ties),
years = paste(start_year, start_year + 9, sep = "-"),
begin = start_year
)
}
starts <- min(seasons):(max(seasons) - 9)
plot_data <- bind_rows(lapply(starts, calculate_windows, records)) |>
arrange(begin, desc(wins), desc(win_pct), franchise) |>
mutate(position = row_number(), .by = begin) |>
filter(position <= 5)The start years stop nine short of the last season, since a window
that starts in 2017 does not have ten seasons in it yet. Ranking happens
inside each window with row_number() after the
arrange(): wins first, win percentage (a tie counts as half
a win) next, and the franchise key last, so a tie that survives both
still sorts the same way every time the site is built.
3. Counting titles
The shading comes from Super Bowls won inside each window. The same
schedule file holds the Super Bowls (game_type == "SB"), so
the winners are one more filter, and the windowing logic is the
same.
champions <- schedules |>
filter(game_type == "SB") |>
transmute(
season,
franchise = sdv_resolve(
if_else(home_score > away_score, home_team, away_team),
league = "nfl"
)
)
titles <- bind_rows(lapply(starts, function(start_year) {
champions |>
filter(between(season, start_year, start_year + 9)) |>
count(franchise, name = "titles") |>
mutate(years = paste(start_year, start_year + 9, sep = "-"))
}))
plot_data <- plot_data |>
left_join(titles, by = c("franchise", "years")) |>
mutate(titles = case_when(
is.na(titles) ~ "0",
titles == 1 ~ "1",
TRUE ~ "2+"
))The join only produces a count for a franchise that won something, so
every other franchise-window comes back NA, which the
case_when() turns into "0". Bucketing into
"0", "1" and "2+" keeps the key
short.
4. Two pivots from one ordered frame
We want one row per window and five franchise columns in rank order.
The cells hold the franchise keys themselves;
gt_sdv_logos() turns them into logos at the end, so the
data never carries an image URL.
pivot_wider() spreads one value column, so pivoting on
the franchise drops the title count. The way around it is to pivot twice
from the same ordered frame, once for what you draw and once for what
you style by.
ordered <- arrange(plot_data, begin, position)
# years + five franchises
wide <- ordered |>
pivot_wider(id_cols = years, names_from = position, values_from = franchise, names_sort = TRUE)
# the shading key, the same shape as the five franchise columns
title_grid <- ordered |>
pivot_wider(id_cols = years, names_from = position, values_from = titles, names_sort = TRUE) |>
select(-years)
mask_1 <- title_grid == "1"
mask_2 <- title_grid == "2+"Sorting once, before either pivot, keeps the two frames in step: both
inherit the same row order and the same column order, so cell
[3, 2] of title_grid describes cell
[3, 2] of wide with no matching afterwards.
Each mask is then a TRUE/FALSE grid the same
shape as the franchise columns. "0" is the base state, so
it needs no mask of its own.
head(wide, 3)
#> # A tibble: 3 × 6
#> years `1` `2` `3` `4` `5`
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 1999-2008 11 17 23 21 10
#> 2 2000-2009 11 17 21 23 9
#> 3 2001-2010 17 11 23 21 95. Color first, then snake
gt_highlight_cells() fills individual cells from a
logical grid of the same shape, which is what the masks are. We run it
on the plain, un-snaked table, then fold it.
wide |>
gt(id = "windows-draft") |>
gt_highlight_cells(-years, condition = mask_1, fill = "#F4D7A4") |>
gt_highlight_cells(-years, condition = mask_2, fill = "#FFA970") |>
gt_snake(n_cols = 2) |>
gt_theme_athletic()| years | 1 | 2 | 3 | 4 | 5 | years | 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1999-2008 | 11 | 17 | 23 | 21 | 10 | 2008-2017 | 17 | 23 | 9 | 1 | 18 | |
| 2000-2009 | 11 | 17 | 21 | 23 | 9 | 2009-2018 | 17 | 23 | 9 | 18 | 26 | |
| 2001-2010 | 17 | 11 | 23 | 21 | 9 | 2010-2019 | 17 | 9 | 23 | 26 | 18 | |
| 2002-2011 | 17 | 11 | 23 | 21 | 9 | 2011-2020 | 17 | 9 | 26 | 23 | 18 | |
| 2003-2012 | 17 | 11 | 23 | 9 | 33 | 2012-2021 | 17 | 26 | 12 | 9 | 23 | |
| 2004-2013 | 17 | 11 | 23 | 24 | 9 | 2013-2022 | 12 | 17 | 26 | 23 | 9 | |
| 2005-2014 | 17 | 11 | 23 | 9 | 24 | 2014-2023 | 12 | 17 | 23 | 9 | 26 | |
| 2006-2015 | 17 | 9 | 11 | 23 | 33 | 2015-2024 | 12 | 23 | 2 | 9 | 33 | |
| 2007-2016 | 17 | 9 | 23 | 11 | 7 | 2016-2025 | 12 | 2 | 33 | 23 | 21 |
gt_snake() rebuilds the table with the rows cut into two
blocks side by side, and it moves any body-cell styling to wherever that
cell ended up. A fill on row 10 of the long grid lands on row one of the
second block, because it is the same cell.
Styles carry through the reshape, but formatting and content
transforms do not: anything from the fmt_*() family,
text_transform(), or the logo helpers has to come after the
snake, and so does the theme. For a parallel frame you cannot apply
before folding, gt_snake_align() reshapes it into the same
blocks so it still lines up.
gt_snake() puts a real empty column between the blocks
rather than padding the seam, because padding shifts the body cells
without shifting the column labels. clean_gaps = TRUE, the
default, strips that spacer’s borders and background so the gap stays
blank under any theme.
6. Logos after the fold
After the fold each column carries its block number as a suffix, so
the franchise columns run 1_1 through 5_2 and
the years are years_1 and years_2. One
gt_sdv_logos() call with a matches() selector
takes all ten franchise columns; each logo’s alt text is
the team’s full name.
# ... |>
gt_sdv_logos(columns = matches("^[1-5]_[0-9]+$"), league = "nfl", height = 34)The pattern picks the snaked columns out by name rather than listing
ten of them, so the code survives a change to n_cols.
7. Spanners, widths and rules
The rest is ordinary gt: a spanner over each block, a
label and a fixed width for the years, and a dotted rule between the
logos.
# ... |>
cols_align(columns = everything(), "center") |>
tab_spanner(columns = matches("^[1-5]_1$"), label = "Most wins in the window", id = "block_1") |>
tab_spanner(columns = matches("^[1-5]_2$"), label = "Most wins in the window", id = "block_2") |>
cols_label(starts_with("years") ~ "Years", matches("^[1-5]_") ~ "") |>
cols_width(starts_with("years") ~ px(92), matches("^[1-5]_") ~ px(48)) |>
tab_options(table_body.hlines.style = "solid", table_body.hlines.color = "black") |>
tab_style(
locations = cells_body(columns = c(starts_with("years"), matches("^[1-5]_"))),
style = cell_borders(color = "black", sides = "right", weight = px(1.5), style = "dotted")
)The dotted rule is set to px(1.5) because
gt_theme_athletic() already draws a thin solid separator on
every column, and a lighter rule loses to it.
8. The legend and the caption
gt_legend_discrete() takes a named vector of
label = color and draws the key in the header.
label_placement = "inside" prints each label on its own
swatch and picks the text color per swatch for contrast, which keeps the
white swatch readable. gt_538_caption() takes a top line,
which gets a rule under it, and a bottom line for the source. Apply it
after the theme, since it reads the themed table to borrow a matching
rule color.
The finished table
title_key <- c(
"No Super Bowls in window" = "#FFFFFF",
"1 Super Bowl in window" = "#F4D7A4",
"2+ Super Bowls in window" = "#FFA970"
)
wide |>
gt(id = "windows") |>
gt_highlight_cells(-years, condition = mask_1, fill = "#F4D7A4") |>
gt_highlight_cells(-years, condition = mask_2, fill = "#FFA970") |>
gt_snake(n_cols = 2) |>
gt_theme_athletic() |>
gt_sdv_logos(columns = matches("^[1-5]_[0-9]+$"), league = "nfl", height = 34) |>
cols_align(columns = everything(), "center") |>
tab_spanner(columns = matches("^[1-5]_1$"), label = "Most wins in the window", id = "block_1") |>
tab_spanner(columns = matches("^[1-5]_2$"), label = "Most wins in the window", id = "block_2") |>
cols_label(starts_with("years") ~ "Years", matches("^[1-5]_") ~ "") |>
cols_width(starts_with("years") ~ px(92), matches("^[1-5]_") ~ px(48)) |>
tab_options(table_body.hlines.style = "solid", table_body.hlines.color = "black") |>
tab_style(
locations = cells_body(columns = c(starts_with("years"), matches("^[1-5]_"))),
style = cell_borders(color = "black", sides = "right", weight = px(1.5), style = "dotted")
) |>
gt_legend_discrete(
title_key,
location = "top", label_placement = "inside", shape = "square",
gap = 8, swatch_size = 20, border_width = 1.5, border_color = "black",
heading = "The NFL's winningest franchises",
subtitle = paste0(
"Regular-season wins in each 10-year window, ", min(seasons), "-", max(seasons),
". Ties in the ranking go to win percentage."
),
heading_style = list(
font = "Spline Sans Mono", size = 22, weight = 600,
transform = "uppercase", margin_bottom = 2, align = "center"
),
subtitle_style = list(
font = "Spline Sans Mono", size = 12, margin_bottom = 6,
align = "center", color = "black"
),
label_style = list(font = "Spline Sans Mono", size = 12, margin_bottom = 12)
) |>
gt_538_caption(
"Relocated franchises count as one: the Raiders, Chargers and Rams across their moves.",
"Data: nflverse schedules via nflreadr"
)The NFL's winningest franchises
Regular-season wins in each 10-year window, 1999-2025. Ties in the ranking go to win percentage.
No Super Bowls in window1 Super Bowl in window2+ Super Bowls in window
|
||||||||||||
| Years1 |
Most wins in the window
|
1 | Years1 |
Most wins in the window
|
||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | |||
| 1999-2008 | ![]() |
![]() |
![]() |
![]() |
![]() |
2008-2017 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2000-2009 | ![]() |
![]() |
![]() |
![]() |
![]() |
2009-2018 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2001-2010 | ![]() |
![]() |
![]() |
![]() |
![]() |
2010-2019 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2002-2011 | ![]() |
![]() |
![]() |
![]() |
![]() |
2011-2020 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2003-2012 | ![]() |
![]() |
![]() |
![]() |
![]() |
2012-2021 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2004-2013 | ![]() |
![]() |
![]() |
![]() |
![]() |
2013-2022 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2005-2014 | ![]() |
![]() |
![]() |
![]() |
![]() |
2014-2023 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2006-2015 | ![]() |
![]() |
![]() |
![]() |
![]() |
2015-2024 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 2007-2016 | ![]() |
![]() |
![]() |
![]() |
![]() |
2016-2025 | ![]() |
![]() |
![]() |
![]() |
![]() |
|
| 1 Relocated franchises count as one: the Raiders, Chargers and Rams across their moves. | ||||||||||||
| Data: nflverse schedules via nflreadr | ||||||||||||
Three franchises hold the top spot between them: the Colts in the first two windows, the Patriots in every window from 2001-2010 to 2012-2021, and the Chiefs in every window since.
Related SportsDataverse packages
- sdvplot - the Python plotting package
- sdvplot-js - the JavaScript / TypeScript plotting packages
- cfbfastR - college football
- hoopR - men’s basketball
- wehoop - women’s basketball
- baseballr - baseball
- fastRhockey - hockey
- oddsapiR - betting odds
- sportyR - playing surfaces
- sportsdataverse-py - the Python package
- sportsdataverse-R - the R meta-package













