On this page
A schedule matrix puts one team per row and one week per column, and draws each opponent’s logo in the cell where they met. This article builds two:
- A conference’s season: every SEC football team’s 2025 schedule, each cell shaded by whether the game was at home, away or on a neutral field, with the byes marked.
- One team’s season: the 2025-26 NBA team with the best record, laid out as a calendar, each game shaded by whether it was won or lost.
The catch in both is the coloring. A cell’s fill is a decision made
cell by cell, not column by column. gt_highlight_cells()
fills individual cells by a condition and
gt_legend_discrete() draws the matching key;
gt_sdv_logos() turns the opponents into logos. The SEC
schedule comes from cfbfastR
(CollegeFootballData.com, which needs a free API key) and the NBA games
from hoopR’s ESPN
team box scores.
gt_highlight_cells() and
gt_legend_discrete() come from Andrew Weatherman’s gtUtils, and the
SEC matrix follows his schedule-matrix example.
1. A conference’s season, one row per team
load_cfb_schedules() holds one row per game.
nflreadr::clean_homeaway() doubles it into one row per team
per game, renaming the home_* / away_* pairs
to team_* / opponent_* and adding a
location of "home" or "away". We
fold the schedule’s neutral_site flag into that column as a
third value, then keep the SEC. Week 15 holds only the conference title
games, so the matrix stops at week 14.
schedule <- cfbfastR::load_cfb_schedules(2025)The output below uses a snapshot of that call taken on November 09,
2026, the one the college football tutorial
reads, because CollegeFootballData.com caps a key’s calls each month and
the site build cannot rely on one;
data-raw/article_fixtures.R takes it with the same
code.
sec <- schedule |>
filter(season_type == "regular", week <= 14) |>
select(week, home_team, away_team, home_conference, away_conference, neutral_site) |>
nflreadr::clean_homeaway() |>
filter(team_conference == "SEC") |>
mutate(location = if_else(neutral_site %in% TRUE, "neutral", location)) |>
select(team, week, location, opponent) |>
arrange(team, week)The cells will hold the opponents’ school names, not image URLs.
gt_sdv_logos() resolves a name like
"Texas A&M" or "Central Arkansas" to the
school’s logo when the table renders, FBS and FCS alike.
opponents <- sec |>
pivot_wider(id_cols = team, names_from = week, values_from = opponent, names_sort = TRUE)
wk <- setdiff(names(opponents), "team")names_sort = TRUE sorts the week columns. Because
week is numeric they come out 1 through
14 in order rather than 1, 10, 11, ....
2. Carrying the location as a parallel mask
pivot_wider() on the opponent loses the location. Pivot
it into its own frame of the same shape and turn each state into a
logical mask.
locations <- sec |>
pivot_wider(id_cols = team, names_from = week, values_from = location, names_sort = TRUE)
locations <- locations[match(opponents$team, locations$team), wk]
home_mask <- locations == "home"
away_mask <- locations == "away"
neutral_mask <- locations == "neutral"The match() line holds locations to the row
order of opponents, so the two frames line up cell for
cell. Each mask is a TRUE/FALSE grid the same
shape as the week columns, which is what
gt_highlight_cells() takes. A bye is a missing opponent, so
it needs no mask: its cells are NA in every mask, which
gt_highlight_cells() reads as no match.
3. Filling cells by condition
gt_highlight_cells() fills the cells in a block of
columns that meet a condition: either a formula tested against each
column, like ~ is.na(.x), or a logical matrix the same
shape as the columns, which is what the masks are. It is the per-cell
counterpart to gtExtras::gt_highlight_cols() and
gt_highlight_rows(), which fill whole columns or rows.
Anything in ... goes to gt::cell_text() for
the filled cells, which is how the byes get small grey type.
# ... |>
gt_highlight_cells(all_of(wk), condition = home_mask, fill = "#FBE7C6") |>
gt_highlight_cells(all_of(wk), condition = away_mask, fill = "#FFFFFF") |>
gt_highlight_cells(all_of(wk), condition = neutral_mask, fill = "#CFE0C3") |>
gt_highlight_cells(
all_of(wk),
condition = ~ is.na(.x), fill = "#E4E4E4",
text_color = "#565C64", size = px(9), weight = 600
)4. Logos for the opponents and the team
gt_sdv_logos() takes a whole block of columns, so one
call draws every opponent; each logo’s alt text is the
school’s full name. On the team column, include_name = TRUE
keeps the name beside its logo. The byes are filled in with
sub_missing(); a cell that names no team passes through
gt_sdv_logos() as text.
# ... |>
sub_missing(columns = all_of(wk), missing_text = "BYE") |>
gt_sdv_logos(columns = all_of(wk), league = "cfb", height = 24) |>
gt_sdv_logos(columns = team, league = "cfb", height = 22, include_name = TRUE)5. The legend
gt_legend_discrete() draws the key. Pass it a named
vector of label = color and it renders the swatches, using
the same fills as the cells so the key matches the table.
label_placement = "inside" prints each label on its swatch,
with the text color chosen per swatch for contrast. The
heading_style and label_style lists take the
same keys as every other style list in the package (see Styling Headers, Legends and Captions).
The SEC matrix
location_key <- c(
"Home game" = "#FBE7C6", "Away game" = "#FFFFFF",
"Neutral site" = "#CFE0C3", "Bye week" = "#E4E4E4"
)
opponents |>
gt(id = "sec-schedule") |>
gt_theme_scoreboard() |>
gt_highlight_cells(all_of(wk), condition = home_mask, fill = "#FBE7C6") |>
gt_highlight_cells(all_of(wk), condition = away_mask, fill = "#FFFFFF") |>
gt_highlight_cells(all_of(wk), condition = neutral_mask, fill = "#CFE0C3") |>
gt_highlight_cells(
all_of(wk),
condition = ~ is.na(.x), fill = "#E4E4E4",
text_color = "#565C64", size = px(9), weight = 600
) |>
sub_missing(columns = all_of(wk), missing_text = "BYE") |>
gt_sdv_logos(columns = all_of(wk), league = "cfb", height = 24) |>
gt_sdv_logos(columns = team, league = "cfb", height = 22, include_name = TRUE) |>
cols_align(columns = all_of(wk), "center") |>
cols_width(team ~ px(136), all_of(wk) ~ px(36)) |>
cols_label(team = "") |>
gt_border_grid(weight = 2) |>
tab_options(table_body.hlines.width = px(2), data_row.padding = px(3)) |>
tab_style(locations = cells_column_labels(), cell_text(size = px(13))) |>
tab_style(locations = cells_body(columns = team), cell_text(size = px(13), weight = 600)) |>
gt_legend_discrete(
location_key,
location = "top", label_placement = "inside", shape = "square",
gap = 8, swatch_size = 20, border_width = 1.5, border_color = "black",
heading = "2025 SEC football schedules",
heading_style = list(
font = "Oswald", size = 32, weight = 600,
transform = "uppercase", margin_bottom = -2
),
label_style = list(font = "Oswald", size = 13, margin_bottom = 6)
) |>
gt_538_caption(
"Regular season, weeks 1-14. The SEC title game (week 15) is left out.",
"Data: CollegeFootballData.com via cfbfastR"
)2025 SEC football schedules
Home gameAway gameNeutral siteBye week
|
||||||||||||||
| 1 | 11 | 21 | 31 | 41 | 51 | 61 | 71 | 81 | 91 | 101 | 111 | 121 | 131 | 141 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Alabama |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
Arkansas |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
Auburn |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
Florida |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
Georgia |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
Kentucky |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
LSU |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
Mississippi State |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
Missouri |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
Oklahoma |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
Ole Miss |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
South Carolina |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
Tennessee |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
Texas |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
Texas A&M |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
Vanderbilt |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
![]() |
![]() |
BYE | ![]() |
![]() |
| 1 Regular season, weeks 1-14. The SEC title game (week 15) is left out. | ||||||||||||||
| Data: CollegeFootballData.com via cfbfastR | ||||||||||||||
gt_border_grid() draws the black rules between every
cell, which is what makes the fills read as a grid of games rather than
a striped list.
6. One team’s season as a calendar
The second matrix turns a single season into a calendar: one row per week, one column per weekday, the opponent’s logo on each game day. We find the team with the most regular-season wins (alphabetical order breaks a tie, so a rebuild picks the same team), then keep its games.
# the NBA Cup final is filed as a regular-season game but counts in no record
cup_final <- hoopR::load_nba_schedule(2026) |>
filter(type_abbreviation == "CC") |>
pull(id)
nba <- hoopR::load_nba_team_box(2026) |>
# regular season; the box scores also hold the All-Star games
filter(season_type == 2, team_abbreviation %in% sdv_teams("nba")$abbr, !game_id %in% cup_final)
best <- nba |>
summarise(wins = sum(team_winner), losses = sum(!team_winner), .by = team_abbreviation) |>
arrange(desc(wins), team_abbreviation) |>
slice(1)
days <- c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
games <- nba |>
filter(team_abbreviation == best$team_abbreviation) |>
mutate(
weekday = as.integer(format(game_date, "%u")),
day = days[weekday],
week_of = game_date - (weekday - 1)
) |>
select(week_of, day, opponent = opponent_team_abbreviation, won = team_winner)format(game_date, "%u") gives the weekday as a number,
Monday 1 to Sunday 7, whatever language the
computer runs in, and subtracting it walks each game back to the Monday
of its week. Two pivots from the same frame give the logos and the
results, and complete() adds any week without a game (an
All-Star break can leave one), so the weeks run down the calendar
without a gap.
all_weeks <- seq(min(games$week_of), max(games$week_of), by = 7)
calendar <- games |>
pivot_wider(id_cols = week_of, names_from = day, values_from = opponent) |>
complete(week_of = all_weeks)
results <- games |>
pivot_wider(id_cols = week_of, names_from = day, values_from = won) |>
complete(week_of = all_weeks)
# every weekday as a column, in calendar order
calendar <- calendar[c("week_of", days)]
results <- results[days]
calendar <- calendar |>
mutate(week = paste(month.abb[as.integer(format(week_of, "%m"))], as.integer(format(week_of, "%d")))) |>
select(week, all_of(days))
win_mask <- results == TRUE
loss_mask <- results == FALSEmonth.abb names the week in English whatever the system
locale is, so the labels match on every machine that builds this
page.
gt_theme_sdv_team() dresses the calendar in the team’s
own colors: the title block in its primary color and the line under the
labels in its secondary. The fills sit under the logos, so a glance down
the grid shows the streaks.
team_name <- sdv_teams("nba")$name[sdv_teams("nba")$abbr == best$team_abbreviation]
calendar |>
gt(id = "team-calendar") |>
gt_highlight_cells(all_of(days), condition = win_mask, fill = "#CDEBD3") |>
gt_highlight_cells(all_of(days), condition = loss_mask, fill = "#F6CFCB") |>
sub_missing(columns = all_of(days), missing_text = "") |>
gt_sdv_logos(columns = all_of(days), league = "nba", height = 26) |>
cols_label(week = "Week of") |>
cols_align(columns = all_of(days), "center") |>
cols_width(week ~ px(78), all_of(days) ~ px(52)) |>
tab_header(
title = paste0(team_name, ", ", best$wins, "-", best$losses),
subtitle = "The 2025-26 regular season, game by game"
) |>
gt_theme_sdv_team(team = best$team_abbreviation, league = "nba", density = "compact") |>
gt_border_grid(color = "#FFFFFF", weight = 2) |>
gt_legend_discrete(
c("Win" = "#CDEBD3", "Loss" = "#F6CFCB"),
location = "bottom", label_placement = "inside", shape = "rounded",
swatch_size = 18, gap = 6
) |>
tab_source_note("Data: ESPN box scores via hoopR")| Oklahoma City Thunder, 64-18 | |||||||
| The 2025-26 regular season, game by game | |||||||
| Week of | Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|---|
| Oct 20 | ![]() |
![]() |
![]() |
||||
| Oct 27 | ![]() |
![]() |
![]() |
![]() |
|||
| Nov 3 | ![]() |
![]() |
![]() |
![]() |
|||
| Nov 10 | ![]() |
![]() |
![]() |
||||
| Nov 17 | ![]() |
![]() |
![]() |
![]() |
|||
| Nov 24 | ![]() |
![]() |
![]() |
||||
| Dec 1 | ![]() |
![]() |
![]() |
||||
| Dec 8 | ![]() |
![]() |
|||||
| Dec 15 | ![]() |
![]() |
|||||
| Dec 22 | ![]() |
![]() |
![]() |
![]() |
|||
| Dec 29 | ![]() |
![]() |
![]() |
![]() |
|||
| Jan 5 | ![]() |
![]() |
![]() |
![]() |
|||
| Jan 12 | ![]() |
![]() |
![]() |
||||
| Jan 19 | ![]() |
![]() |
![]() |
![]() |
|||
| Jan 26 | ![]() |
![]() |
![]() |
||||
| Feb 2 | ![]() |
![]() |
![]() |
||||
| Feb 9 | ![]() |
![]() |
![]() |
||||
| Feb 16 | ![]() |
![]() |
|||||
| Feb 23 | ![]() |
![]() |
![]() |
![]() |
|||
| Mar 2 | ![]() |
![]() |
![]() |
||||
| Mar 9 | ![]() |
![]() |
![]() |
||||
| Mar 16 | ![]() |
![]() |
![]() |
||||
| Mar 23 | ![]() |
![]() |
![]() |
![]() |
|||
| Mar 30 | ![]() |
![]() |
![]() |
||||
| Apr 6 | ![]() |
![]() |
![]() |
![]() |
|||
WinLoss
|
|||||||
| Data: ESPN box scores via hoopR | |||||||
gt_legend_discrete(location = "bottom") sets the key
under the table, where it does not compete with the team-colored title
block. A white gt_border_grid() separates the fills without
drawing a hard rule through the logos.
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

Alabama











Arkansas












Florida









































































