Author: Greg Lin - GitHub - LinkedIn
Table: Popular Movies table.
Repo: Popular Movies vingette on the reactable
package site.
Data: The Movie Database (TMDb) and the TMDb API. Raw data: tmdb_movies.json
. This product uses the TMDb API but is not endorsed or certified by TMDb.
Code
The code chunks below replicate the R code and HTML to replicate the the Popular Movies table.
# The code below replicates the Popular Movies table when placed into an R Markdown document.
library(reactable)
library(jsonlite)
library(dplyr)
library(crosstalk)
library(htmltools)
# tmdb_movies.json is a JSON dataset with 2000 rows and 22 columns.
# Some of these columns contain nested lists (e.g., multiple genres per movie).
movies <- fromJSON("tmdb_movies.json") %>%
mutate(
popularity_rank = rank(-popularity, ties.method = "first"),
year = substr(release_date, 1, 4),
score = vote_average * 10
) %>%
arrange(popularity_rank)
tbl_data <- movies %>%
select(popularity_rank, title, release_date, score, id, poster_path, certification, genres, runtime)
# Precalculate user score colors to be used for custom cell rendering
get_score_color <- function(score) {
blue_pal <- function(x) rgb(colorRamp(c("#9fc7df", "#416ea4"))(x), maxColorValue = 255)
normalized <- (score - min(score)) / (max(score) - min(score))
blue_pal(normalized)
}
tbl_data <- mutate(tbl_data, score_color = get_score_color(score))
# Shared data for the table
shared_data <- SharedData$new(tbl_data)
# Shared data for the Crosstalk filters. This is a separate shared data object
# so we can filter on columns that aren't in the table. The same group is used
# to link the two datasets together.
shared_movies <- SharedData$new(movies, group = shared_data$groupName())
tbl <- reactable(
shared_data,
defaultColDef = colDef(headerClass = "header"),
columns = list(
popularity_rank = colDef(
name = "Rank",
align = "center",
minWidth = 70,
maxWidth = 90
),
title = colDef(
name = "Movie",
# Since there are so many rows, we use a JS render function to keep the
# page size down. This would be much easier to code in R, but that would
# significantly increase the file size.
cell = JS([1412 chars quoted with '"']),
html = TRUE,
minWidth = 250
),
release_date = colDef(
name = "Year",
defaultSortOrder = "desc",
cell = JS("function(cellInfo) {
return cellInfo.value.slice(0, 4)
}"),
minWidth = 70,
maxWidth = 90
),
score = colDef(
name = "User Score",
defaultSortOrder = "desc",
# Show the user score in a donut chart like TMDb does. Since donut charts
# are hard to compare, apply a color scale as well.
cell = JS("function(cellInfo) {
const sliceColor = cellInfo.row['score_color']
const sliceLength = 2 * Math.PI * 24
const sliceOffset = sliceLength * (1 - cellInfo.value / 100)
const donutChart = (
'<svg width=60 height=60 style=\"transform: rotate(-90deg)\" focusable=false>' +
'<circle cx=30 cy=30 r=24 fill=none stroke-width=4 stroke=rgba(0,0,0,0.1)></circle>' +
'<circle cx=30 cy=30 r=24 fill=none stroke-width=4 stroke=' + sliceColor +
' stroke-dasharray=' + sliceLength + ' stroke-dashoffset=' + sliceOffset + '></circle>' +
'</svg>'
)
const label = '<div style=\"position: absolute; top: 50%; left: 50%; ' +
'transform: translate(-50%, -50%)\">' + cellInfo.value + '%' + '</div>'
return '<div style=\"display: inline-flex; position: relative\">' + donutChart + label + '</div>'
}"),
html = TRUE,
align = "center",
width = 140,
class = "user-score"
),
id = colDef(show = FALSE),
poster_path = colDef(show = FALSE),
certification = colDef(show = FALSE),
genres = colDef(show = FALSE),
runtime = colDef(show = FALSE),
score_color = colDef(show = FALSE)
),
highlight = TRUE,
language = reactableLang(
noData = "No movies found",
pageInfo = "{rowStart}\u2013{rowEnd} of {rows} movies"
),
theme = reactableTheme(
highlightColor = "#f3fafb",
borderColor = "hsl(0, 0%, 93%)",
headerStyle = list(borderColor = "hsl(0, 0%, 90%)"),
# Vertically center cells
cellStyle = list(display = "flex", flexDirection = "column", justifyContent = "center")
),
class = "movies-tbl"
)
# Custom Crosstalk select filter. This is a single-select input that works
# on columns containing multiple values per row (list columns).
select_filter <- function(id, label, shared_data, group, choices = NULL,
width = "100%", class = "filter-input") {
values <- shared_data$data()[[group]]
keys <- shared_data$key()
if (is.list(values)) {
# Multiple values per row
flat_keys <- unlist(mapply(rep, keys, sapply(values, length)))
keys_by_value <- split(flat_keys, unlist(values), drop = TRUE)
choices <- if (is.null(choices)) sort(unique(unlist(values))) else choices
} else {
# Single value per row
keys_by_value <- split(seq_along(keys), values, drop = TRUE)
choices <- if (is.null(choices)) sort(unique(values)) else choices
}
script <- sprintf("
window['__ct__%s'] = (function() {
const handle = new window.crosstalk.FilterHandle('%s')
const keys = %s
return {
filter: function(value) {
if (!value) {
handle.clear()
} else {
handle.set(keys[value])
}
}
}
})()
", id, shared_data$groupName(), toJSON(keys_by_value))
div(
class = class,
tags$label(`for` = id, label),
tags$select(
id = id,
onchange = sprintf("window['__ct__%s'].filter(this.value)", id),
style = sprintf("width: %s", validateCssUnit(width)),
tags$option(value = "", "All"),
lapply(choices, function(value) tags$option(value = value, value))
),
tags$script(HTML(script))
)
}
# Custom Crosstalk search filter. This is a free-form text field that does
# case-insensitive text searching on a single column.
search_filter <- function(id, label, shared_data, group, width = "100%", class = "filter-input") {
values <- as.list(shared_data$data()[[group]])
values_by_key <- setNames(values, shared_data$key())
script <- sprintf("
window['__ct__%s'] = (function() {
const handle = new window.crosstalk.FilterHandle('%s')
const valuesByKey = %s
return {
filter: function(value) {
if (!value) {
handle.clear()
} else {
// Escape special characters in the search value for regex matching
value = value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\DISCOURSE_PLACEHOLDER_3')
const regex = new RegExp(value, 'i')
const filtered = Object.keys(valuesByKey).filter(function(key) {
const value = valuesByKey[key]
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (regex.test(value[i])) {
return true
}
}
} else {
return regex.test(value)
}
})
handle.set(filtered)
}
}
}
})()
", id, shared_data$groupName(), toJSON(values_by_key))
div(
class = class,
tags$label(`for` = id, label),
tags$input(
id = id,
type = "search",
oninput = sprintf("window['__ct__%s'].filter(this.value)", id),
style = sprintf("width: %s", validateCssUnit(width))
),
tags$script(HTML(script))
)
}
# Custom Crosstalk range filter. This is a simple range input that only filters
# minimum values of a column.
range_filter <- function(id, label, shared_data, group, min = NULL, max = NULL,
step = NULL, suffix = "", width = "100%", class = "filter-input") {
values <- shared_data$data()[[group]]
values_by_key <- setNames(as.list(values), shared_data$key())
script <- sprintf("
window['__ct__%s'] = (function() {
const handle = new window.crosstalk.FilterHandle('%s')
const valuesByKey = %s
return {
filter: function(value) {
const filtered = Object.keys(valuesByKey).filter(function(key) {
return valuesByKey[key] >= value
})
handle.set(filtered)
}
}
})()
", id, shared_data$groupName(), toJSON(values_by_key))
min <- if (!is.null(min)) min else min(values)
max <- if (!is.null(max)) max else max(values)
value <- min
oninput <- paste(
sprintf("document.getElementById('%s__value').textContent = this.value + '%s';", id, suffix),
sprintf("window['__ct__%s'].filter(this.value)", id)
)
div(
class = class,
tags$label(`for` = id, label),
div(
tags$input(
id = id,
type = "range",
min = min,
max = max,
step = step,
value = value,
oninput = oninput,
onchange = oninput, # For IE11 support
style = sprintf("width: %s", validateCssUnit(width))
)
),
span(id = paste0(id, "__value"), paste0(value, suffix)),
tags$script(HTML(script))
)
}
div(
class = "movies",
div(
class = "filters",
search_filter("filter_title", "Search titles", shared_movies, "title"),
select_filter("filter_genres", "Genre", shared_movies, "genres"),
select_filter("filter_year", "Year", shared_movies, "year",
choices = sort(unique(movies$year), decreasing = TRUE)),
select_filter("filter_language", "Language", shared_movies, "original_language"),
select_filter("filter_rating", "Rating", shared_movies, "certification",
choices = c("G", "PG", "PG-13", "R", "NC-17", "NR")),
range_filter("filter_score", "Min Score", shared_movies, "score", suffix = "%")
),
tags$hr(),
tbl
)
### CSS Styling
/* Font from https://fontsarena.com/hanken-grotesk-by-hanken-design-co/ */
@font-face {
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 400;
src: url("fonts/HKGrotesk-Regular.woff2") format("woff2"),
url("fonts/HKGrotesk-Regular.woff") format("woff");
}
@font-face {
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 600;
src: url("fonts/HKGrotesk-SemiBold.woff2") format("woff2"),
url("fonts/HKGrotesk-SemiBold.woff") format("woff");
}
@font-face {
font-family: 'Hanken Grotesk';
font-style: normal;
font-weight: 700;
src: url("fonts/HKGrotesk-Bold.woff2") format("woff2"),
url("fonts/HKGrotesk-Bold.woff") format("woff");
}
.movies {
font-family: 'Hanken Grotesk', Helvetica, Arial, sans-serif;
font-size: 16px;
}
.movies h2 {
font-weight: 600;
}
.movies a {
color: #007899;
}
.movies-tbl {
margin-top: 16px;
}
.header {
color: hsl(0, 0%, 45%);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.header:hover[aria-sort],
.header[aria-sort='ascending'],
.header[aria-sort='descending'] {
color: hsl(0, 0%, 5%);
}
.movie-info {
display: flex;
align-items: center;
}
.movie-info-text {
margin-left: 12px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
}
.movie-info-details {
margin-top: 2px;
font-size: 14px;
font-weight: 400;
color: hsl(0, 0%, 40%);
overflow: hidden;
text-overflow: ellipsis;
}
.movie-poster {
width: 45px;
height: 68px;
max-width: none; /* Reset rmarkdown/pkgdown style */
box-shadow: 0 0 0 1px hsl(0, 0%, 95%);
}
.movie-runtime {
display: inline-block;
}
.movie-rating {
margin-right: 4px;
padding: 0 4px;
border: 1px solid hsl(0, 0%, 75%);
border-radius: 2px;
}
.user-score {
font-weight: 600;
}
.filters {
display: flex;
flex-wrap: wrap;
margin-top: 16px;
margin-left: -32px;
}
.filter-input {
margin-top: 8px;
margin-left: 32px;
flex: 1;
min-width: 250px;
}
.filter-input label {
color: hsl(0, 0%, 45%);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.filter-input select,
.filter-input input[type="search"] {
padding: 0 6px;
height: 32px;
}
.filter-input input[type="search"] {
/* Reset Bootstrap 3 styles */
-webkit-appearance: searchfield;
}
.filter-input input[type="search"]::-webkit-search-cancel-button {
/* Reset Bootstrap 3 styles */
-webkit-appearance: searchfield-cancel-button;
}
.filter-input input[type="range"] {
/* Reset for IE11 */
padding: 0;
}
reactable, single-table-example, interactive-html, crosstalk