Skip to contents
72%


Source: 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.

Source Code

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(
    genres = lapply(genres, as.list),
    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(vAlign = "center", 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("renderMovie"),
      html = TRUE,
      minWidth = 250
    ),
    release_date = colDef(
      name = "Year",
      defaultSortOrder = "desc",
      cell = JS("cellInfo => 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("renderUserScore"),
      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%)")
  ),
  class = "movies-tbl"
)
// Custom JavaScript cell renderer for the Movie column
function renderMovie(cellInfo) {
  const url = 'https://www.themoviedb.org/movie/' + cellInfo.row['id']
  const imageSrc = 'https://www.themoviedb.org/t/p/w45' + cellInfo.row['poster_path']
  const altText = cellInfo.value + ' movie poster'
  const poster = `<a href="${url}"><img src="${imageSrc}" class="movie-poster" alt="${altText}"></a>`

  const title = `<a href="${url}">${cellInfo.value}</a>`

  let rating = cellInfo.row['certification']
  if (rating) {
    rating = `<span class="movie-rating">${rating}</span>`
  } else {
    rating = ''
  }

  const genres = ' ' + cellInfo.row['genres'].join(', ')

  let runtime = cellInfo.row['runtime']
  if (runtime != null) {
    const hours = Math.floor(runtime / 60)
    const mins = runtime % 60
    runtime = [hours > 0 ? hours + 'h' : '', mins > 0 ? mins + 'm' : ''].join(' ')
    runtime = `<span aria-hidden="true"> • </span>` +
      `<div class="movie-runtime">${runtime}</div>`
  } else {
    runtime = ''
  }

  const details = `<div class="movie-info-details">${rating}${genres}${runtime}</div>`
  const text = `<div class="movie-info-text">${title}${details}</div>`
  return `<div class="movie-info">${poster}${text}</div>`
}

// Custom JavaScript cell renderer for the User Score column
function renderUserScore(cellInfo) {
  return donutChart(cellInfo.value, cellInfo.row['score_color'])
}

// Generates HTML for a donut chart given a percentage value (out of 100) and color
function donutChart(value, color) {
  // All units are in rem for relative scaling
  const radius = 1.5
  const diameter = 3.75
  const center = diameter / 2
  const width = 0.25
  const sliceLength = 2 * Math.PI * radius
  const sliceOffset = sliceLength * (1 - value / 100)
  const donutChart = `
    <svg width="${diameter}rem" height="${diameter}rem" style="transform: rotate(-90deg)" focusable="false">
      <circle cx="${center}rem" cy="${center}rem" r="${radius}rem" fill="none" stroke-width="${width}rem" stroke="rgba(0,0,0,0.1)"></circle>
      <circle cx="${center}rem" cy="${center}rem" r="${radius}rem" fill="none" stroke-width="${width}rem" stroke="${color}"
       stroke-dasharray="${sliceLength}rem" stroke-dashoffset="${sliceOffset}rem"></circle>
    </svg>
  `
  const label = `
    <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)">
      ${value}%
    </div>
  `
  return `
    <div style="display: inline-flex; position: relative">
      ${donutChart}
      ${label}
    </div>
  `
}
# 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, '\\\\$&')
            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
)
/* 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;
}

.movies h2 {
  font-weight: 600;
}

.movies a {
  color: #007899;
  text-decoration: none;
}

.movies a:hover,
.movies a:focus {
  text-decoration: underline;
  text-decoration-thickness: max(1px, 0.0625rem);
}

.movies-tbl {
  margin-top: 1rem;
  font-size: 1rem;
}

.header {
  color: hsl(0, 0%, 45%);
  font-weight: 700;
  font-size: 0.8125rem;
  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: 0.75rem;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
}

.movie-info-details {
  margin-top: 0.125rem;
  font-size: 0.875rem;
  font-weight: 400;
  color: hsl(0, 0%, 40%);
  overflow: hidden;
  text-overflow: ellipsis;
}

.movie-poster {
  width: 45px;
  height: 68px;
  box-shadow: 0 0 0 1px hsl(0, 0%, 95%);
}

.movie-runtime {
  display: inline-block;
}

.movie-rating {
  margin-right: 0.25rem;
  padding: 0 0.25rem;
  border: 1px solid hsl(0, 0%, 75%);
  border-radius: 2px;
}

.user-score {
  font-weight: 600;
}

.filters {
  display: flex;
  flex-wrap: wrap;
  margin-top: 1rem;
  margin-left: -32px;
}

.filter-input {
  margin-top: 0.5rem;
  margin-left: 32px;
  flex: 1;
  min-width: 250px;
}

.filter-input label {
  color: hsl(0, 0%, 45%);
  font-weight: 700;
  font-size: 0.8125rem;
  letter-spacing: 0.4px;
  text-transform: uppercase;
}

.filter-input select,
.filter-input input[type="search"] {
  padding: 0 0.375rem;
  height: 2rem;
}

.filter-input input[type="search"] {
  /* Revert Bootstrap 5's Reboot styles, which change native search input styling */
  -webkit-appearance: searchfield;
  outline-offset: revert;
  border-color: revert;
}