## table of dates
<- tibble::tibble(
df_dates name = c("Alice", "Bob", "Carol", "Carol", "Dan", "Alice", "Bob", "Carol", "Carol", "Dan", "Alice"),
var = c(rep(c("start", "end"), each = 5), "empty"),
date = c("2024-10-05", "2024-10-13", "2024-09-10", "2024-11-11", "2024-10-01", "2025-01-03", "2024-12-20", "2025-01-04", "2024-12-20", "2025-01-10", "2024-12-05")
|>
) ::nest(dates = c(var, date))
tidyr
## table of employee details (inc. dates)
<- tibble::tibble(
df name = c("Alice", "Bob", "Carol", "Dan"),
level = c("Director", "VP", "Director", "Assoc Director"),
division = c("HR", "sales", "sales", "HR"),
notes = c("n/a", "n/a", "n/a", "Dan has been with us for 5 years"),
projects = c("project 1;project 2;project 3", "project 2", "project 1;project 3", "project 2;project 3;project 4"),
division_badge = c("HR", "sales", "sales", "HR"),
alert = c(TRUE, TRUE, FALSE, FALSE)
|>
) ::left_join(df_dates, by = "name")
dplyr
## create gt table
|>
df ::gt() |>
gtgt_subtitle(col_title = name, col_subtitle = level, col_parenthesis = division, col_tootlip = notes) |>
gt_dots(projects, items = c("project 1", "project 2", "project 3", "project 4"), sep = ";", tooltip = TRUE) |>
gt_alert(alert) |>
gt_badge(division_badge, palette = c(HR = "#2244CC", sales = "#22CC44")) |>
gt_timeline(dates, min_date = "2024-09-01", max_date = "2025-01-13", palette = c(start = "#00AA00", end = "#AA0000"))
Introduction
The {gt} package is a great R package for building tables and the outputs that can be generated, particularly in HTML, are stunning. The {gtExtras} pacakge, from Tom Mock, extends the functionality of {gt}. In developing a Quarto report, I found that I needed some tabular outputs that were not present in {gt}. Taking inspiration from {gtExtras}, here’s some code and explanation, on adding additional functions to {gt}.
TL/DR
Here’s a quick illustration of the extensions discussed in the blog post.
.gtIndex()
The gt_index()
function is a useful function lifted from the {gtExtras} package. It returns the underlying data of a column and is used extensively in gtExtras functions. Rather than rely on a dependence to {gtExtras} I’ve extracted the function, included here as .gtindex()
.
#' gtindex taken from gtExtras package
#'
<- function(gt_object, column, as_vector = TRUE) {
.gtindex stopifnot("'gt_object' must be a 'gt_tbl', have you accidentally passed raw data?" = "gt_tbl" %in% class(gt_object))
stopifnot("'as_vector' must be a TRUE or FALSE" = is.logical(as_vector))
if (length(gt_object[["_row_groups"]]) >= 1) {
# if the data is grouped you need to identify the group column
# and arrange by that column. I convert to a factor so that the
# columns don't default to arrange by other defaults
# (ie alphabetical or numerical)
<- gt_object[["_row_groups"]]
gt_row_grps
<- gt_object[["_stub_df"]] |>
grp_vec_ord ::mutate(group_id = factor(group_id, levels = gt_row_grps)) |>
dplyr::arrange(group_id) |>
dplyr::pull(rownum_i)
dplyr
<- gt_object[["_data"]] |>
df_ordered ::slice(grp_vec_ord)
dplyrelse {
} # if the data is not grouped, then it will just "work"
<- gt_object[["_data"]]
df_ordered
}
# return as vector or tibble in correct, gt-indexed ordered
if (isTRUE(as_vector)) {
|>
df_ordered ::pull({{ column }})
dplyrelse {
}
df_ordered
} }
gt_badge
this first function simply replaces a column with colored badges. {gtExtras} includes a gt_badge()
function itself but
- For some reason it’s missing from the pkgdown site so I did not know it existed.
- I required additional functionality - for example, the ability to work with missing data.
- It was a good first example to sink my teeth into.
The function is shown below followed by some explanatory text.
#' Add a badge based on values
#'
#' This function differs from gtExtras::gt_badge() in that it accounts for values
#' missing in the color palette and missing in the data. Those missing in the
#' color palette are displayed as a white badge with grey border and those missing
#' in the data are represented as an empty white badge.
#'
#' @param gt_object An existing gt object
#' @param column The column to convert to dots
#' @param palette Named vector of values and colors.
#'
#' @export
#'
<- function(gt_object, column, palette = c()) {
gt_badge stopifnot("Table must be of class 'gt_tbl'" = "gt_tbl" %in% class(gt_object))
<- .gtindex(gt_object, {{ column }})
cell_contents
::text_transform(
gt
gt_object,locations = gt::cells_body(columns = {{ column }}),
fn = function(x) {
::map(cell_contents, function(y) {
purrrif (is.na(y)) {
'<span class = "gtbadge gtbadge-empty">none</span>'
else if (y %in% names(palette)) {
} ::glue('<span class = "gtbadge" style = "background-color: {palette[y]};">{y}</span>')
glueelse {
} ::glue('<span class = "gtbadge gtbadge-clear">{y}</span>')
glue
}
})
}|>
) ::opt_css(
gtcss = "
.gtbadge {
display: inline-block;
color: #ffffff;
min-width: 30px;
padding: 2px 4px;
text-align: center;
border-radius: 7px;
font-size: .8em;
}
.gtbadge-empty {
background-color: #ffffff;
border: 1px solid #dddddd;
}
.gtbadge-clear {
background-color: #ffffff;
color: #999999;
border: 1px solid #dddddd;
}
"
) }
First we capture the contents of column
by using the .gtindex()
function:
cell_contents <- .gtindex(gt_object, {{ column }})
- return the contents of column column
as a vector (this function is taken from {gtExtras}).
Next, gt::text_transform()
is used to replace the data in column
with new values returned by a function. gt::text_transform()
is a powerful function that takes three arguments: a gt table, locations for transformation (in this case a column identifier) and a function that returns a character vector the same length as the column entries. Here, our function iterates over cell_contents
, the vector of data in column
and returns a badge. Badges are colored according to a palette passed to gt_badge()
, accounting for values missing in the palette as well as empty values.
Finally, the output from gt::text_transform()
is formatted by declaring the column as markdown and adding css classes.
Example Output
<- data.frame(ref = seq(1:5), data = c("badge_1", "badge_2", "badge_1", NA, "badge_3"))
df |> gt::gt() |> gt_badge(data, palette = c(badge_1 = "#990000", badge_2 = "#009900")) df
ref | data |
---|---|
1 | badge_1 |
2 | badge_2 |
3 | badge_1 |
4 | none |
5 | badge_3 |
gt_alert()
This is another simple example. Here an icon is returned in a column based on TRUE/FALSE values.
#' Replace a logical column with an alert indicator
#'
#' Replace TRUE values with an empty circle and any other values with a red exclamation
#' mark in a circle. Setting `invert`=TRUE reverses this behavior.
#'
#' @param gt_object An existing gt object
#' @param column The column to convert to dots
#' @param invert If TRUE then invert the response so that TRUE = alert. Default is FALSE
#'
#' @export
#'
<- function(gt_object, column, invert = FALSE) {
gt_alert stopifnot("Table must be of class 'gt_tbl'" = "gt_tbl" %in% class(gt_object))
<- .gtindex(gt_object, {{ column }})
cell_contents
::text_transform(
gt
gt_object,locations = gt::cells_body(columns = {{ column }}),
fn = function(x) {
::map(cell_contents, function(y) {
purrr<- isTRUE(y)
true_val if (invert == TRUE) {
<- !true_val
true_val
}if (true_val) {
::fa("circle", fill = "#cccccc")
fontawesomeelse {
} ::fa("circle-exclamation", fill = "#990000")
fontawesome
}
})
}|>
) ::fmt_markdown(columns = {{ column }})
gt }
Example Output
<- data.frame(ref = seq(1:5), data = c(TRUE, FALSE, NA, TRUE, FALSE))
df |> gt::gt() |> gt_alert(data, invert = TRUE) df
ref | data |
---|---|
1 | |
2 | |
3 | |
4 | |
5 |
gt_dots()
This function displays a vector of values as colored dots. It’s useful for groups containing multiple categorical values. Column data may be input through a list column, eg list(c(“A”, “B”, “C”)) or character-separated, eg “A,B,C”. gt_dots()
also introduces the use of tooltips. Code explanation and an example follow below.
#' Replace a column with a series of colored dot rows
#'
#' @param gt_object An existing gt object
#' @param column The column to convert to dots
#' @param items Vector of values for dots. This represents all of the possible dots in
#' order
#' @param sep Optional separation character. If NULL (default) then it is assumed that
#' column `column` is a list column containing vectors where the member of each vector
#' can be a value in `items`. For example, if `items` is c("A", "B", "C") then `column`
#' could contain data such as "A" or c("A", "B"). If a `sep` is a character then column
#' `column` should be a character vector with values separated by `sep`. For example, if
#' `items` is c("A", "B", "C") and `sep` is ";" then `column` could contain data such as
#' "A" or "A;B".
#' @param tooltip If TRUE then add a tooltip indicating the active values
#'
#' @return gt table
#'
#' @export
#'
<- function(gt_object, column, items = c(), sep = NULL, tooltip = FALSE) {
gt_dots stopifnot("Table must be of class 'gt_tbl'" = "gt_tbl" %in% class(gt_object))
<- .gtindex(gt_object, {{ column }})
cell_contents
<- colorRampPalette(c("#89CFF1", "#003A6B"))
pal <- pal(length(items))
cols <- lapply(seq_along(items), function(i) {
l_dots ::fa("fas fa-circle", fill = cols[i], margin_left = '.05em', margin_right = '.05em')
fontawesome|>
}) setNames(items)
<- fontawesome::fa("far fa-circle", fill = "#cccccc", margin_left = '.05em', margin_right = '.05em')
blank
<- rlang::quo_name(rlang::quo({{column}}))
col_name <- length(items) * 1.1 + 1
width_val <- as.formula(paste0(col_name, " ~ '", width_val, "em'"))
colwidth
::text_transform(
gt
gt_object,locations = gt::cells_body(columns = {{ column }}),
fn = function(x) {
lapply(cell_contents, function(y) {
# split to create a vector
if (!is.null(sep)) {
<- unlist(strsplit(y, sep, fixed = TRUE))
y
}
# find matches
<- match(y, items)
dot_matches <- rep(blank, times = length(items))
dots if (!is.na(dot_matches[1])) {
for (i in dot_matches) {
<- l_dots[[i]]
dots[i]
}
}
<- paste(dots, collapse = "")
output_dots
if (tooltip == TRUE) {
if (!is.na(dot_matches[1])) {
<- paste(items[dot_matches], collapse = ", ")
tooltip_text else {
} <- NA
tooltip_text
}
::glue(
glue"<div data-bs-toggle='tooltip' data-bs-placement='right' data-bs-title=\"{tooltip_text}\">{output_dots}</div>"
)
else {
}
output_dots
}
})
}|>
) ::fmt_markdown(columns = {{ column }}) |>
gt::cols_width(colwidth)
gt }
At the beginning of the function we build a named vector of colors. We’ve hardcoded shades of blue but could easily pass the two color-extremes as parameters to gt_dots()
. The output is l_dots
, a named list of colored icons, and blank
, an empty icon.
<- colorRampPalette(c("#89CFF1", "#003A6B"))
pal <- pal(length(items))
cols <- lapply(seq_along(items), function(i) {
l_dots ::fa("fas fa-circle", fill = cols[i], margin_left = '.05em', margin_right = '.05em')
fontawesome|>
}) setNames(items)
<- fontawesome::fa("far fa-circle", fill = "#cccccc", margin_left = '.05em', margin_right = '.05em') blank
The next part of the code builds a formula defining the column width. The column width is defined by the number of dots. This keeps all the dots on a single line.
<- rlang::quo_name(rlang::quo({{column}}))
col_name <- length(items) * 1.1 + 1
width_val <- as.formula(paste0(col_name, " ~ '", width_val, "em'")) colwidth
Finally we run the gt::text_transform()
function, looping over each vector of data. The output is a series of colored dots, corresponding to matches against a vector. If requested, a tootlip is added for each column cell.
Note, to use tooltips in a quarto HTML document, Bootstrap tooltips need to first be activated. This can be done by adding the following in the document’s yaml header:
include-after-body:
- text: "<script>\n const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle=\"tooltip\"]')\n
\ const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl, {html: true}))\n</script>\n"
Example Output
<- data.frame(ref = seq(1:5), data = c("p1,p2", "p1", NA, "p3", "p2,p3"))
df |> gt::gt() |> gt_dots(data, items = c("p1", "p2", "p3", "p4"), sep = ",", tooltip = TRUE) df
ref | data |
---|---|
1 | |
2 | |
3 | |
4 | |
5 |
gt_subtitle()
This function is very similar to the {gtExtras} function gt_merge_stack()
, taking two columns and stacking the text of the first above the second. This adds optional text in parenthesis (useful in the case of a grouping value or tag) and a tooltip. It demonstrates how a relatively simple function may be used to build some creative table columns.
#' Add subtitle and tooltip to column
#'
#' This function is similar to gtExtras::gt_merge_stack(). It replaces the `col_title`
#' column with a formatted column that stacks `col_title` on top of `col_subtitle`.
#' The upper text is converted to small caps and the lower text is smaller and grey.
#' If `col_parenthesis` is included, values from an additional column are added to
#' the title in parenthesis. This can be used to include a column grouping or tag.
#' If `col_tooltip` is included then values from this column will be used as data for
#' tooltips. Output is returned in column `col_title` with columns `col_subtitle`,
#' `col_parenthesis` and `col_tooltip` removed.
#'
#' @param gt_object An existing gt object
#' @param col_title The column holding the title
#' @param col_subtitle The column holding the subtitle
#' @param col_parenthesis Optional column holding data to be added in parentheses after
#' the title. Often used to include a grouped column or tag
#' @param col_tootlip Optional column holding data to be used as tooltip text
#'
#' @export
#'
<- function(gt_object, col_title, col_subtitle, col_parenthesis = NULL, col_tootlip = NULL) {
gt_subtitle stopifnot("Table must be of class 'gt_tbl'" = "gt_tbl" %in% class(gt_object))
<- .gtindex(gt_object, {{ col_subtitle }})
col_subtitle_contents <- rlang::quo_is_null(rlang::enquo(col_parenthesis))
col_parenthesis_missing <- rlang::quo_is_null(rlang::enquo(col_tootlip))
col_tootlip_missing
if (!col_parenthesis_missing) {
<- .gtindex(gt_object, {{ col_parenthesis }})
col_parenthesis_contents
}
if (!col_tootlip_missing) {
<- .gtindex(gt_object, {{ col_tootlip }})
col_tootlip_contents
}
<- gt::text_transform(
rtn
gt_object,locations = gt::cells_body(columns = {{ col_title }}),
fn = function(x) {
if (!col_parenthesis_missing) {
<- glue::glue("{x} ({col_parenthesis_contents})")
txt else {
} <- x
txt
}
if (!col_tootlip_missing) {
::glue(
glue"<div data-bs-toggle='tooltip' data-bs-placement='right' data-bs-title=\"{col_tootlip_contents}\">
<div class='subtitle-top'>
<span>{txt}</span>
</div>
<div class='subtitle-bottom'>
<span>{col_subtitle_contents}</span>
</div>
</div>"
)
else {
}
::glue(
glue"<div class='subtitle-top'>
<span>{txt}</span>
</div>
<div class='subtitle-bottom'>
<span>{col_subtitle_contents}</span>
</div>"
)
}
}|>
) ::opt_css(
gtcss = "
.subtitle-top {
line-height:10px;
text-align:left;
}
.subtitle-top span {
font-weight: bold;
font-variant: small-caps;
font-size: 14px;
}
.subtitle-bottom {
line-height:12px;
text-align:left;
}
.subtitle-bottom span {
font-weight: bold;
color: grey;
font-size: 10px
}
"
)
<- rtn |>
rtn ::cols_hide({{col_subtitle}})
gtif (!col_parenthesis_missing) {
<- rtn |>
rtn ::cols_hide({{col_parenthesis}})
gt
}if (!col_tootlip_missing) {
<- rtn |>
rtn ::cols_hide({{col_tootlip}})
gt
}
rtn
}
Example Output
<- data.frame(name = c("Name 1", "Name 2"), title = c("VP", "Chair"), gp = c("AAA", "AAA"), tt = c("First Person", "Second Person"))
df |> gt::gt() |> gt_subtitle(col_title = name, col_subtitle = title, col_parenthesis = gp, col_tootlip = tt) df
name |
---|
Name 1 (AAA)
VP
|
Name 2 (AAA)
Chair
|
gt_timeline()
This function adds a timeline. Dates are plotted as markers along a time axis for each cell in a column. The dates are stored as tibbles for each row. The dates tibbles contain a var
column for labels and a date
column for dates. Dates can be color-coded by var
and also include a hover to highlight var
and date
characteristics.
#' Create a timeline
#'
#' Create a linear date line with markers representing dates. The input column is
#' expected to be a list column of tibbles, each with a `var` column holding labels
#' and a `date` column holding dates.
#'
#' @param gt_object An existing gt object
#' @param column The column to convert to a timeline
#' @param min_date Minimum date (format = yyyy-mm-dd). If missing then the minimum date
#' is determined from the data
#' @param max_date Maximum date (format = yyyy-mm-dd). If missing then the maximum date
#' is determined from the data
#' @param palette Named vector of colors (optional). If included then color named values
#' from the `var` column accordingly
#' @param add_key If TRUE and a palette is included then include a color key as a table
#' footnote
#'
<- function(gt_object, column, min_date = NULL, max_date = NULL, palette = c(), add_key = FALSE) {
gt_timeline
stopifnot("Table must be of class 'gt_tbl'" = "gt_tbl" %in% class(gt_object))
<- "#ADD8E6"
default_fill if (length(palette) == 0) {
<- tibble::tibble(var = character(), fill_color = character())
df_cols else {
} <- tibble::enframe(palette, name = "var", value = "fill_color")
df_cols
}
<- .gtindex(gt_object, {{ column }})
cell_contents
if (any(missing(min_date), missing(max_date))) {
<- cell_contents |>
v_times ::bind_rows() |>
dplyr::pull(date)
dplyrif (missing(min_date)) {
<- min(v_times)
min_date
}if (missing(max_date)) {
<- max(v_times)
max_date
}
}
<- as.numeric(difftime(as.Date(max_date), as.Date(min_date), 'days'))
day_range <- 150 # width
w
<- gt::text_transform(
rtn
gt_object,locations = gt::cells_body(columns = {{ column }}),
fn = function(x) {
lapply(cell_contents, function(d) {
<- d |>
plt_d ::mutate(days = as.numeric(difftime(as.Date(date), as.Date(min_date), 'days'))) |>
dplyr::mutate(xval = w * days / day_range) |>
dplyr::mutate(textanchor = dplyr::case_when(
dplyr< 0.4 * w ~ "start",
xval > 0.6 * w ~ "end",
xval .default = "middle"
|>
)) ::left_join(df_cols, by = "var") |>
dplyr::mutate(fill_color = dplyr::if_else(is.na(fill_color), default_fill, fill_color))
dplyr
<- glue::glue('<line x1="0" y1="17" x2="{w+10}" y2="17" style="stroke: #808080; stroke-width: 1" />')
svg_line
<- plt_d |>
svg_pts ::glue_data('
glue <g class = "gttime">
<circle class="gttimedot" cx="{xval+5}" cy="17" r="5" stroke="#000" stroke-width="1" fill="{fill_color}" />
<text class="gttimelab" x="{xval+5}" y="9" font-size=".6em" text-anchor="{textanchor}">{var} ({date})</text>
</g>
') |>
::glue_collapse(sep = "")
glue
::glue('<svg width="{w+10}" height="23" xmlns=http://www.w3.org/2000/svg>{svg_line}{svg_pts}</svg>')
glue
})
}|>
) ::fmt_markdown(columns = {{ column }}) |>
gt::opt_css(
gtcss = "
.gttime {
overflow: visible;
}
.gttimelab {
display: none;
overflow: visible;
}
.gttime:hover {
text {display: block;}
}
"
)
if (add_key == TRUE & length(palette) > 0) {
<- lapply(seq_along(palette), function(i) {
color_key paste0("<span style = 'margin-right: 15px'><span style = 'margin-right: 5px'>", bsicons::bs_icon('circle-fill', color = palette[i]), "</span><span>", names(palette)[i], "</span></span>")
|>
}) paste(collapse = "")
<- paste0("<span><span style = 'margin-right: 20px;'>Key:</span>", color_key, "</span>")
color_key
<- rtn |>
rtn ::tab_footnote(footnote = gt::html(color_key), locations = gt::cells_column_labels({{column}}))
gt
}
return(rtn)
}
The function works by defining a variable day_range
which is the number of days for the timeline (max - min). Next, each date is expressed as the fraction along the timeline multiplied by the width of the output. Cells are constructed by building up SVG graphics as follows:
- plot the timeline as a horizontal line just below the center of the cell
- for each time point plot it on the timeline within a group of class
gttime
- add text for each timeline within the same group
The css added controls the visibility. Labels are initialized with display: none
so they will be hidden. On hovering we set display: block
so that the label is visible.
Hover labels are positioned so that if they fall in the first 40% of the timeline they are left-justified, if they fall in the last 40% of the timeline they are right-justified and if they fall in the middle 20% they are centered. This helps position labels within the timeline region, avoiding overflow.
Example Output
<- data.frame(ref = c(1, 1, 2, 2),
df var = c("start", "end", "start", "end"),
date = c("2024-10-04", "2024-12-05", "2024-10-25", "2024-12-07")) |>
::nest(dates = c(var, date))
tidyr
df
# A tibble: 2 × 2
ref dates
<dbl> <list>
1 1 <tibble [2 × 2]>
2 2 <tibble [2 × 2]>
|> gt::gt() |> gt_timeline(dates, min_date = "2024-10-01", max_date = "2024-12-31", palette = c(start = "#00AA00", end = "#AA0000")) df
ref | dates |
---|---|
1 | |
2 |