Tables

Building tables with three different packages - kableExtra, DT, and gt. These builds aren’t meant to be extravagant, but are baselines for html and static tables.

kableExtra

Starting with knitr and kableExtra to make html tables. They’re not interactive, but they look nice and require little code. Also, they don’t paginate, which is a pain. In this case, I’ve added links to each player’s wikipedia page using the cell_spec() function and then specifying kbl("html", escape = FALSE).

Some watchouts:

  • Table Height - if you limit the height of the table, the horizontal scroll (if it’s necessary) is at the bottom of the table; it’s not persistent at the base of the table.
  • Justification - by default, numeric columns are right-justified. This data has two columns, BB% and K% that are strings and need to be justified. But, you can’t specify specific columns, it’s a vector of “l”, “c”, or “r”. Column headers follow the justification.

The escape symbol ’\ is showing up in the Salary column b/c some strange interference from DT and/or possibly widgetframe.

mlb %>% 
  select(Name:AVG, `wRC+`, contains("WAR")) %>%
  select(-G19) %>%
  # formattable adds dollar sign and keeps it numeric
  mutate(Salary = formattable::currency(Salary, digits = 0L)) %>% 
  # setting up the URL, new_tab is FALSE by default
  mutate(Name = cell_spec(Name, "html", 
                          link = paste0("https://en.wikipedia.org/wiki/", 
                                        str_replace_all(Name, " ", "_")), 
                          new_tab = TRUE)) %>%
  rename(`2019` = WAR19, `2021` = WAR) %>%
  # table
  kbl("html", escape = FALSE,
    align = c("l","l",rep("r",15)), # justification; by default numeric or right-justified
    # format.args = list(big.mark = ",") # comma formatting if necessary
    ) %>% 
  # added styling with kableExtra
  kable_styling(bootstrap_options = c("striped", "hover", "responsive")) %>%
  add_header_above(c(" " = 15, "WAR" = 2)) %>%
  footnote(general = "Source: FanGraphs") %>%
  scroll_box(width = "100%", height = "600px")  # set height of table

WAR

Name Team Salary G PA HR R RBI SB BB% K% ISO BABIP AVG wRC+ 2019 2021
Trea Turner \$13,000,000 148 646 28 107 77 32 6.3% 17.0% 0.208 0.362 0.328 142 3.6 6.9
Vladimir Guerrero Jr. TOR \$605,400 161 698 48 123 111 4 12.3% 15.8% 0.290 0.313 0.311 166 0.4 6.7
Bryce Harper PHI \$27,538,462 141 599 35 101 84 13 16.7% 22.4% 0.305 0.359 0.309 170 4.5 6.6
Marcus Semien TOR \$18,000,000 162 724 45 115 102 15 9.1% 20.2% 0.273 0.276 0.265 131 7.6 6.6
Juan Soto WSN \$8,500,000 151 654 29 111 95 9 22.2% 14.2% 0.221 0.332 0.313 163 4.9 6.6
Jose Ramirez CLE \$9,400,000 152 636 36 111 103 27 11.3% 13.7% 0.272 0.256 0.266 137 3.6 6.3
Fernando Tatis Jr. SDP \$1,714,286 130 546 42 99 97 25 11.4% 28.0% 0.328 0.324 0.282 156 NA 6.1
Carlos Correa HOU \$11,700,000 148 640 26 104 92 0 11.7% 18.1% 0.205 0.308 0.279 134 NA 5.8
Starling Marte \$12,500,000 120 526 12 89 55 47 8.2% 18.8% 0.148 0.372 0.310 134 3.0 5.5
Aaron Judge NYY \$10,175,000 148 633 39 89 98 6 11.8% 25.0% 0.256 0.332 0.287 148 NA 5.5
Bryan Reynolds PIT \$601,000 159 646 24 93 90 5 11.6% 18.4% 0.220 0.345 0.302 142 3.2 5.5
Brandon Crawford SFG \$15,200,000 138 549 24 79 90 11 10.2% 19.1% 0.224 0.334 0.298 139 0.3 5.5
Tyler O’Neill STL \$594,700 138 537 34 89 80 15 7.1% 31.3% 0.274 0.366 0.286 145 NA 5.4
Cedric Mullins II BAL \$577,000 159 675 30 91 59 30 8.7% 18.5% 0.228 0.322 0.291 136 NA 5.3
Brandon Lowe TBR \$2,500,000 149 615 39 97 99 7 11.1% 27.2% 0.277 0.280 0.247 137 NA 5.2
Xander Bogaerts BOS \$20,000,000 144 603 23 90 79 5 10.3% 18.7% 0.198 0.333 0.295 130 6.8 5.2
Jose Altuve HOU \$29,000,000 146 678 31 117 83 5 9.7% 13.4% 0.211 0.280 0.278 130 3.5 5.2
Shohei Ohtani LAA \$3,000,000 158 639 46 103 100 26 15.0% 29.6% 0.335 0.303 0.257 152 NA 5.1
Matt Olson OAK \$5,000,000 156 673 39 101 111 4 13.1% 16.8% 0.269 0.269 0.271 146 3.9 5.0
Paul Goldschmidt STL \$25,333,333 158 679 31 102 99 12 9.9% 20.0% 0.221 0.331 0.294 138 3.0 4.9
Bo Bichette TOR \$587,800 159 690 29 121 102 25 5.8% 19.9% 0.186 0.339 0.298 122 NA 4.9
Max Muncy LAD \$9,000,000 144 592 36 95 94 2 14.0% 20.3% 0.278 0.257 0.249 140 4.7 4.9
Kyle Tucker HOU \$624,300 140 567 30 83 92 14 9.3% 15.9% 0.263 0.304 0.294 147 NA 4.8
Rafael Devers BOS \$4,575,000 156 664 38 101 113 5 9.3% 21.5% 0.259 0.307 0.279 134 5.9 4.7
Yoan Moncada CHW \$6,800,000 144 616 14 74 61 3 13.6% 25.5% 0.148 0.350 0.263 122 5.6 4.5
Freddie Freeman ATL \$22,359,375 159 695 31 120 83 8 12.2% 15.4% 0.203 0.321 0.300 135 3.9 4.5
J.T. Realmuto PHI \$18,048,556 134 537 17 64 73 13 8.9% 24.0% 0.176 0.325 0.263 108 5.7 4.4
Jake Cronenworth SDP \$584,900 152 643 21 94 71 4 8.6% 14.0% 0.194 0.283 0.266 116 NA 4.4
Manny Machado SDP \$34,000,000 153 640 28 92 106 12 9.8% 15.9% 0.211 0.290 0.278 122 3.2 4.4
Nicky Lopez KCR \$597,500 151 565 2 78 43 22 8.7% 13.1% 0.078 0.347 0.300 106 NA 4.4
Note:
Source: FanGraphs

DT Datable

DT is another html table, but it’s far more interactive. Columns are sortable and filterable by default. We’re going to convert the string columns to numeric so that we can use the DT format functions.

The DT table won’t render via netlify but it will render properly in Rmarkdown or Shiny.

mlb %>%
  select(Name:AVG, `wRC+`, contains("WAR")) %>%
  select(-G19) %>%
  mutate(across(contains("%"), ~as.numeric(str_replace_all(., "%", ""))/100)) %>%
  # create a link to player's wiki 
  mutate(
    link = paste0("https://en.wikipedia.org/wiki/",
                                        str_replace_all(Name, " ", "_")),
    Name = paste0('<a href="',link,'"target="_blank">',Name,"</a>")) %>%
  DT::datatable(
    fillContainer = TRUE, # make it scrollable
    escape = FALSE, # for links to work
    caption = "DT Version - MLB Table",
    class = "display",
    filter = 'top',
    rownames = FALSE,
    extensions = "Buttons",
    options = list(
      dom = "Bfrtip",
      buttons = list( # download data
        list(
        extend = "collection",
        buttons = c("csv", "excel", "pdf"),
        text = "Download"
        )),
      pageLength = 20, # how many rows per page
      columnDefs = list(
        list(
          visible = FALSE, targets = 17) # hiding a column
        ))
    ) %>%
  # formatting numbers
  formatPercentage(c(10,11), digits = 1) %>%
  formatCurrency(columns = "Salary", currency = "$", mark = ",", digits = 0)

gt Tables

Last, gt tables, which are static but very customizable, and they are great for presentations and decks. also, because gt allows you rename columns in the table, we are allowed to have multiple columns with the same name, which generally isn’t allowed in other tables.

It’s also very easy to add imagery and symbols into the tables.

# arrows for fun
up_arrow <- "<span style=\"color:green\">&#9650;</span>"
down_arrow <- "<span style=\"color:red\">&#9660;</span>"

mlb %>% 
  select(Name:AVG, `wRC+`, contains("WAR")) %>% 
  mutate(across(contains("%"), ~as.numeric(str_replace_all(., "%", ""))/100)) %>%
  rename(WAR21 = WAR, G21 = G) %>%
  # set up table
  gt(rowname_col = "Name") %>% 
  # title and subtitle
  tab_header( 
    title = md("**gt Table: MLB Table**"),
    subtitle = "Top 30 by fWAR"
  ) %>%
  # caption 
  tab_source_note( # adding source note
    source_note = md("*Source: FanGraphs*") 
  ) %>% 
  # create rowname groupings 
  tab_row_group(
    label = md("Mega Salary (Over $10MM)"),
    rows = Salary > 10e6
  ) %>% 
  tab_row_group(
    label = "Market-Value (3-$10MM)",
    rows = (Salary > 3e6 & Salary <= 10e6)
  ) %>% 
  tab_row_group(
    label = "Great Deal (1-$3MM)",
    row = (Salary >= 1e6 & Salary <= 3e6)
    ) %>% 
  tab_row_group(
    label = "Highway Robbery (< $1MM)",
    row = (Salary < 1e6)
    ) %>%
  # reorder groups 
  row_group_order(
    groups = c("Mega Salary (Over $10MM)", "Market-Value (3-$10MM)",
               "Great Deal (1-$3MM)", "Highway Robbery (< $1MM)")
  ) %>% 
  # add spanner over WAR
  tab_spanner(
    label = "WAR",
    columns = matches("WAR")
  ) %>% 
  # add spanner over G
  tab_spanner(
    label = "Games",
    columns = c("G19", "G21")
  ) %>%
  # format columns
  fmt_currency(
    columns = "Salary",
    currency = "USD",
    sep_mark = ",",
    suffixing = TRUE, # cut down, e.g, $1M or $650K
    use_subunits = FALSE
    ) %>% 
  fmt_percent(
    columns = matches("BB%|K%"),
    decimals = 1
  ) %>% 
  # styling the table --------------------------- 
  opt_table_font(
    font = list(
      google_font("Open Sans")
    )
  ) %>%
  # aligning the title and subtitle left
  tab_style( 
    style = cell_text(align = 'left',
                      weight = 'bold',
                      size = px(18)),
    locations = cells_title(c("title"))
  ) %>%
  # alignment of spanner
  tab_style(
    style = cell_text(weight = "bold",
                      size = px(13),
                      align = "center"),
    locations = list(
      cells_column_spanners(matches("WAR|Games")))
  ) %>%
  # bolding groups and columns 
  tab_style(
    style = cell_text(weight = 'bold',
                      size = px(13)),
    locations = list(
      cells_column_labels(
        columns = everything()),
      cells_row_groups(groups = TRUE))
  ) %>%
  # align column headers 
  tab_style(
    style = cell_text(align = "center"),
    locations = list(
      cells_column_labels(
        columns = everything()
      ))
  ) %>%
  # indenting the stub (more room for the identifiers)
  tab_style( 
    style = cell_text(indent = px(8),
                      size = px(12)),
    locations = cells_stub()
  ) %>%
   # font size in table
  tab_style(
    style = cell_text(size = px(12)),
    locations = cells_body(
      columns = everything())
  ) %>%
   # font alignment
  tab_style(
    style = cell_text(align = "center"),
    locations = cells_body(
      columns = !contains("Name"))
  ) %>% 
  # adding red/green arrows based on WAR
  text_transform(
    locations = list(
      cells_body(columns = c("WAR21"),
                 rows = WAR21 > WAR19)
    ),
    fn = function(x) paste(x, up_arrow)
  ) %>%
  text_transform(
    locations = list(
      cells_body(columns = c("WAR21"),
                 rows = WAR21 < WAR19)
    ),
    fn = function(x) paste(x, down_arrow)
  ) %>%
  # rename columns if necessary
  cols_label(
    G19 = "2019", G21 = "2021",
    WAR19 = "2019", WAR21 = "2021"
  ) %>%
  # hide columne for space
  cols_hide(
    columns = 'ISO'
  ) %>% 
  # final options
  tab_options(
    table.background.color = "#F3F3F3",
    data_row.padding = px(6),
    row_group.padding = px(6),
    source_notes.font.size = px(10),
    footnotes.font.size = px(10),
    footnotes.marks = "LETTERS",
    table.font.names = "Open Sans"
  )
gt Table: MLB Table
Top 30 by fWAR
Team Salary Games PA HR R RBI SB BB% K% BABIP AVG wRC+ WAR
2019 2021 2019 2021
Mega Salary (Over $10MM)
Trea Turner --- $13M 122 148 646 28 107 77 32 6.3% 17.0% 0.362 0.328 142 3.6 6.9
Bryce Harper PHI $28M 157 141 599 35 101 84 13 16.7% 22.4% 0.359 0.309 170 4.5 6.6
Marcus Semien TOR $18M 162 162 724 45 115 102 15 9.1% 20.2% 0.276 0.265 131 7.6 6.6
Carlos Correa HOU $12M NA 148 640 26 104 92 0 11.7% 18.1% 0.308 0.279 134 NA 5.8
Starling Marte --- $12M 132 120 526 12 89 55 47 8.2% 18.8% 0.372 0.310 134 3.0 5.5
Aaron Judge NYY $10M NA 148 633 39 89 98 6 11.8% 25.0% 0.332 0.287 148 NA 5.5
Brandon Crawford SFG $15M 147 138 549 24 79 90 11 10.2% 19.1% 0.334 0.298 139 0.3 5.5
Xander Bogaerts BOS $20M 155 144 603 23 90 79 5 10.3% 18.7% 0.333 0.295 130 6.8 5.2
Jose Altuve HOU $29M 124 146 678 31 117 83 5 9.7% 13.4% 0.280 0.278 130 3.5 5.2
Paul Goldschmidt STL $25M 161 158 679 31 102 99 12 9.9% 20.0% 0.331 0.294 138 3.0 4.9
Freddie Freeman ATL $22M 158 159 695 31 120 83 8 12.2% 15.4% 0.321 0.300 135 3.9 4.5
J.T. Realmuto PHI $18M 145 134 537 17 64 73 13 8.9% 24.0% 0.325 0.263 108 5.7 4.4
Manny Machado SDP $34M 156 153 640 28 92 106 12 9.8% 15.9% 0.290 0.278 122 3.2 4.4
Market-Value (3-$10MM)
Juan Soto WSN $8M 150 151 654 29 111 95 9 22.2% 14.2% 0.332 0.313 163 4.9 6.6
Jose Ramirez CLE $9M 129 152 636 36 111 103 27 11.3% 13.7% 0.256 0.266 137 3.6 6.3
Matt Olson OAK $5M 127 156 673 39 101 111 4 13.1% 16.8% 0.269 0.271 146 3.9 5.0
Max Muncy LAD $9M 141 144 592 36 95 94 2 14.0% 20.3% 0.257 0.249 140 4.7 4.9
Rafael Devers BOS $5M 156 156 664 38 101 113 5 9.3% 21.5% 0.307 0.279 134 5.9 4.7
Yoan Moncada CHW $7M 132 144 616 14 74 61 3 13.6% 25.5% 0.350 0.263 122 5.6 4.5
Great Deal (1-$3MM)
Fernando Tatis Jr. SDP $2M NA 130 546 42 99 97 25 11.4% 28.0% 0.324 0.282 156 NA 6.1
Brandon Lowe TBR $2M NA 149 615 39 97 99 7 11.1% 27.2% 0.280 0.247 137 NA 5.2
Shohei Ohtani LAA $3M NA 158 639 46 103 100 26 15.0% 29.6% 0.303 0.257 152 NA 5.1
Highway Robbery (< $1MM)
Vladimir Guerrero Jr. TOR $605K 123 161 698 48 123 111 4 12.3% 15.8% 0.313 0.311 166 0.4 6.7
Bryan Reynolds PIT $601K 134 159 646 24 93 90 5 11.6% 18.4% 0.345 0.302 142 3.2 5.5
Tyler O'Neill STL $595K NA 138 537 34 89 80 15 7.1% 31.3% 0.366 0.286 145 NA 5.4
Cedric Mullins II BAL $577K NA 159 675 30 91 59 30 8.7% 18.5% 0.322 0.291 136 NA 5.3
Bo Bichette TOR $588K NA 159 690 29 121 102 25 5.8% 19.9% 0.339 0.298 122 NA 4.9
Kyle Tucker HOU $624K NA 140 567 30 83 92 14 9.3% 15.9% 0.304 0.294 147 NA 4.8
Jake Cronenworth SDP $585K NA 152 643 21 94 71 4 8.6% 14.0% 0.283 0.266 116 NA 4.4
Nicky Lopez KCR $598K NA 151 565 2 78 43 22 8.7% 13.1% 0.347 0.300 106 NA 4.4
Source: FanGraphs
Taylor Grant
Taylor Grant
Group Director, Strategy & Analytics
Next
Previous