What This Code-Through Covers

Most data we use in class sits in a file: a CSV, an RDS, something you load once. This code-through tours the other kind of source, a live API that gives you a new answer every time you ask.

The API is ThemeParks.wiki (api.themeparks.wiki). It is free, public, and returns real-time wait times, ride status, and schedules for theme parks worldwide, including every Disney park. The core endpoints need no API key.

By the end you will have pulled a live dataset, caught a silent join failure, fixed it, and put the result on a real map with filter widgets.

It also introduces several packages for working with web data and maps. They are listed with links to their documentation in the last section.

The API in a Nutshell

An API call is a URL that returns JSON instead of a webpage. Your code sends a request to an endpoint (one specific URL). The server sends back a response. For this API the response is JSON, a text format built from nested lists of named values. R reads that JSON and turns it into a list, which we then reshape into a data frame.

The cycle is the same for any API: request, response, parse, reshape, analyze. Four endpoints matter here.

Endpoint Returns
/v1/destinations Every resort the API tracks
/v1/entity/{id}/children Every ride, show, and restaurant inside a park
/v1/entity/{id}/live Live wait times and ride status
/v1/entity/{id}/history/daily Historical daily wait statistics

Server-side data refreshes roughly every five minutes, so polling faster buys nothing. The API also limits how fast one user can send requests. Calls that come too fast get an error (HTTP status 429, “too many requests”), so a real project paces its requests.

Step 1: Find a Destination

One call to /v1/destinations returns every resort. We search it by name instead of hardcoding an ID.

base_url <- "https://api.themeparks.wiki/v1"

get_json <- function(path) {
  request(paste0(base_url, path)) |>
    req_perform() |>
    resp_body_json()
}

destinations <- get_json("/destinations")$destinations

dlr <- destinations |>
  keep(~ .x$name == "Disneyland Resort") |>
  pluck(1)

parks <- map_dfr(dlr$parks, ~ tibble(park_id = .x$id, park_name = .x$name))
parks
## # A tibble: 2 × 2
##   park_id                              park_name                       
##   <chr>                                <chr>                           
## 1 832fcd51-ea19-4e77-85c7-75d5843b127c Disney California Adventure Park
## 2 7340550b-c14d-4def-80bb-acdb51d49a66 Disneyland Park

Two rows come back, one for each park. Each ID is what we use in every later call.

Before the reshaping, the raw response is a nested list. Here is the top level of one destination:

str(destinations[[1]], max.level = 1)
## List of 5
##  $ id        : chr "643e837e-b244-4663-8d3a-148c26ecba9c"
##  $ name      : chr "SeaWorld Parks and Resorts Orlando"
##  $ slug      : chr "seaworldorlandoresort"
##  $ externalId: chr "seaworldorlandoresort"
##  $ parks     :List of 3

A nested list does not fit in a table. That is why we reshape it into a data frame. A short guide to the functions in the chunk above:

  • request(), req_perform(), and resp_body_json() (httr2) build the request, send it, and read the JSON that comes back.
  • keep() filters a list down to the items that match a test. pluck(1) takes the first one.
  • map_dfr() runs a function on every item in a list and stacks the results into one data frame.
  • %||% (used in the next step) supplies a default when a value is missing, so a ride without coordinates becomes NA instead of causing an error.

Step 2: Pull Everything Inside a Park

/children on a park ID returns the whole subtree: attractions, shows, and restaurants, each with a name, entity type, and coordinates.

get_children <- function(park_id, park_name) {
  kids <- get_json(paste0("/entity/", park_id, "/children"))$children
  map_dfr(kids, ~ tibble(
    park_name   = park_name,
    entity_id   = .x$id,
    name        = .x$name,
    entity_type = .x$entityType,
    lat         = .x$location$latitude  %||% NA_real_,
    lon         = .x$location$longitude %||% NA_real_
  ))
}

entities <- map2_dfr(parks$park_id, parks$park_name, get_children)

entities |> count(park_name, entity_type) |> kable()
park_name entity_type n
Disney California Adventure Park ATTRACTION 29
Disney California Adventure Park RESTAURANT 36
Disney California Adventure Park SHOW 22
Disneyland Park ATTRACTION 53
Disneyland Park RESTAURANT 30
Disneyland Park SHOW 27
attractions <- entities |> filter(entity_type == "ATTRACTION")
attractions |> select(name, lat, lon) |> head(5) |> kable(caption = "Raw attraction rows")
Raw attraction rows
name lat lon
Treat Trails at Oogie Boogie Bash 33.80757 -117.9183
Soarin’ Across America 33.80825 -117.9200
Goofy’s Sky School 33.80632 -117.9228
Pixar Pal-A-Round – Non-Swinging 33.80503 -117.9224
The Little Mermaid - Ariel’s Undersea Adventure 33.80626 -117.9211

Step 3: The Land Problem (and a Silent Join Failure)

The API has no LAND entity type. Its types are DESTINATION, PARK, ATTRACTION, RESTAURANT, HOTEL, and SHOW. To group crowds by themed area you build the grouping yourself.

My first attempt was a lookup table joined on exact ride names.

naive_lookup <- tribble(
  ~name,                ~land,
  "it's a small world", "Fantasyland",
  "Space Mountain",     "Tomorrowland"
)

attractions |>
  left_join(naive_lookup, by = "name") |>
  count(matched = !is.na(land)) |>
  kable(caption = "Exact-name join: how many rides matched?")
Exact-name join: how many rides matched?
matched n
FALSE 81
TRUE 1

Almost nothing matched, and R raised no error. The API writes names with curly quotes and punctuation (it’s a small world), so a lookup typed with straight quotes never matches. A left join fails silently. Always count the unmatched rows after a join.

The fix has two parts. First, normalize names and match on keywords. Second, give every ride that still has no land the land of its nearest matched ride in the same park, using the API’s own coordinates.

normalize_name <- function(x) {
  x |>
    str_to_lower() |>
    str_replace_all("[\u2018\u2019\u02bc]", "'") |>
    str_replace_all("[\u201c\u201d]", "\"") |>
    str_replace_all("[\u2013\u2014]", "-")
}

anchors <- tribble(
  ~keyword,                   ~land,
  "main street",              "Main Street, U.S.A.",
  "space mountain",           "Tomorrowland",
  "astro blasters",           "Tomorrowland",
  "autopia",                  "Tomorrowland",
  "star tours",               "Tomorrowland",
  "matterhorn",               "Fantasyland",
  "small world",              "Fantasyland",
  "peter pan",                "Fantasyland",
  "dumbo",                    "Fantasyland",
  "runaway railway",          "Mickey's Toontown",
  "roger rabbit",             "Mickey's Toontown",
  "big thunder",              "Frontierland",
  "mark twain",               "Frontierland",
  "tiana",                    "Critter Country",
  "winnie the pooh",          "Critter Country",
  "pirates of the caribbean", "New Orleans Square",
  "haunted mansion",          "New Orleans Square",
  "indiana jones",            "Adventureland",
  "jungle cruise",            "Adventureland",
  "rise of the resistance",   "Star Wars: Galaxy's Edge",
  "smugglers run",            "Star Wars: Galaxy's Edge",
  "red car trolley",          "Buena Vista Street",
  "mike & sulley",            "Hollywood Land",
  "tower of terror",          "Hollywood Land",
  "guardians of the galaxy",  "Avengers Campus",
  "web slingers",             "Avengers Campus",
  "radiator springs racers",  "Cars Land",
  "luigi",                    "Cars Land",
  "mater",                    "Cars Land",
  "incredicoaster",           "Pixar Pier",
  "pixar pal-a-round",        "Pixar Pier",
  "toy story midway",         "Pixar Pier",
  "soarin",                   "Grizzly Peak",
  "grizzly river run",        "Grizzly Peak",
  "little mermaid",           "Paradise Gardens Park",
  "goofy's sky school",       "Paradise Gardens Park",
  "silly symphony swings",    "Paradise Gardens Park"
)

match_land <- function(nm) {
  hit <- anchors$land[str_detect(nm, fixed(anchors$keyword))]
  if (length(hit) > 0) hit[1] else NA_character_
}

assign_nearest <- function(df) {
  anchored <- df |> filter(!is.na(land), !is.na(lat))
  df |> mutate(land = pmap_chr(list(land, lat, lon), function(l, la, lo) {
    if (!is.na(l)) return(l)
    if (is.na(la) || nrow(anchored) == 0) return("Unmapped")
    d <- (anchored$lat - la)^2 + (anchored$lon - lo)^2
    anchored$land[which.min(d)]
  }))
}

attractions <- attractions |>
  mutate(land = map_chr(normalize_name(name), match_land)) |>
  group_by(park_name) |>
  group_modify(~ assign_nearest(.x)) |>
  ungroup()

attractions |> count(park_name, land) |> kable(caption = "Attractions per land after the fix")
Attractions per land after the fix
park_name land n
Disney California Adventure Park Avengers Campus 4
Disney California Adventure Park Cars Land 4
Disney California Adventure Park Grizzly Peak 4
Disney California Adventure Park Hollywood Land 5
Disney California Adventure Park Paradise Gardens Park 5
Disney California Adventure Park Pixar Pier 7
Disneyland Park Adventureland 3
Disneyland Park Critter Country 3
Disneyland Park Fantasyland 13
Disneyland Park Frontierland 4
Disneyland Park Main Street, U.S.A. 8
Disneyland Park Mickey’s Toontown 7
Disneyland Park New Orleans Square 5
Disneyland Park Star Wars: Galaxy’s Edge 2
Disneyland Park Tomorrowland 8

Nearest-neighbor assignment is approximate near land borders. Check the table and add a keyword for any ride that lands in the wrong place.

Step 4: Pull Live Wait Times

/live on a park ID returns the park and everything inside it: standby wait, status (OPERATING, DOWN, CLOSED, REFURBISHMENT), and show times.

get_live <- function(park_id) {
  live <- get_json(paste0("/entity/", park_id, "/live"))$liveData
  map_dfr(live, ~ tibble(
    entity_id = .x$id,
    status    = .x$status %||% NA_character_,
    wait_min  = .x$queue$STANDBY$waitTime %||% NA_real_
  ))
}

live_data <- map_dfr(parks$park_id, get_live)

crowd_snapshot <- attractions |>
  left_join(live_data, by = "entity_id") |>
  filter(!is.na(wait_min), !is.na(lat))

crowd_snapshot |> select(name, land, status, wait_min) |> head(5) |> kable()
name land status wait_min
Soarin’ Across America Grizzly Peak OPERATING 45
Pixar Pal-A-Round – Non-Swinging Pixar Pier OPERATING 15
Radiator Springs Racers Cars Land OPERATING 65
Inside Out Emotional Whirlwind Pixar Pier OPERATING 10
Jumpin’ Jellyfish Paradise Gardens Park OPERATING 15

The wait times are a snapshot from when this document was knitted: October 05, 2026 at 01:11 PM MST. They do not update after that.

Step 5: Put It on a Map

A scatter plot of latitude and longitude shows dots with no context. Leaflet draws real map tiles under the data. The two Disney parks sit next to each other, so one map covers both. Use the layer control (top right) to switch between satellite and street views.

pal <- colorNumeric(c("#2ecc71", "#f1c40f", "#e74c3c"), domain = crowd_snapshot$wait_min)

crowd_map <- function(d) {
  leaflet(d, height = 560) |>
    addProviderTiles(providers$Esri.WorldImagery, group = "Satellite") |>
    addProviderTiles(providers$CartoDB.Voyager, group = "Streets") |>
    addLayersControl(baseGroups = c("Satellite", "Streets")) |>
    addCircleMarkers(
      lng = ~lon, lat = ~lat,
      radius = ~pmin(6 + wait_min / 6, 18),
      color = "white", weight = 1.5,
      fillColor = ~pal(wait_min), fillOpacity = 0.9,
      label = ~paste0(name, " (", land, "): ", wait_min, " min")
    ) |>
    addLegend(pal = pal, values = crowd_snapshot$wait_min, title = "Wait (min)")
}

crowd_map(crowd_snapshot)

The map needs an internet connection when viewed, because the tiles load from the tile servers.

Step 6: Add Filter Widgets

I wanted Shiny-style filter widgets in this document. Shiny needs a running R server, and a knitted HTML file does not have one, so it is static. I asked Claude, an AI assistant, how to solve that, and it introduced me to the crosstalk package, which was new to me. Crosstalk gives you widgets that run in the browser instead. Linked filters update the map and a table together, with no server.

One limit to know. The data is only live while the document knits. R calls the API once during the knit, and the results are saved inside the HTML file. After that, the wait times are a fixed snapshot of that moment. The filters change which rides you see, but they do not fetch new data. The map tiles still load from the internet when you open the file. To refresh the wait times, knit the document again.

shared <- SharedData$new(
  crowd_snapshot |> select(name, park_name, land, status, wait_min, lat, lon)
)

bscols(
  widths = c(3, 9),
  list(
    filter_select("park", "Park", shared, ~park_name),
    filter_select("land", "Land", shared, ~land),
    filter_slider("wait", "Wait (min)", shared, ~wait_min)
  ),
  crowd_map(shared)
)
datatable(
  shared,
  rownames = FALSE,
  options = list(pageLength = 10),
  colnames = c("Ride", "Park", "Land", "Status", "Wait (min)", "Lat", "Lon")
)

Change a filter above and both the map and the table respond.

Another limit. The Park and Land pickers work independently, so this simple setup does not keep the two parks separate. The Land picker always lists lands from both Disneyland Park and Disney California Adventure. If you pick a park and then pick a land that belongs to the other park, the map and table show nothing, because no ride matches both choices. Crosstalk cannot make one picker depend on another. A Shiny app can, by updating the Land choices whenever the Park changes. I have not built that yet.

Step 7: When You Need Real Shiny

Crosstalk covers filtering. When the app must fetch fresh data on a button click, or run R code in response to input, you need Shiny and a host such as shinyapps.io. The pattern has three pieces.

  1. A widget (selectInput) captures what the user wants.
  2. A reactive expression re-filters the data when the input changes.
  3. A render function (renderLeaflet) redraws from that filtered data.
library(shiny)

ui <- fluidPage(
  selectInput("park_filter", "Park",
              choices = c("Both parks", unique(crowd_snapshot$park_name))),
  leafletOutput("crowd_map", height = 560)
)

server <- function(input, output) {
  filtered <- reactive({
    if (input$park_filter == "Both parks") crowd_snapshot
    else filter(crowd_snapshot, park_name == input$park_filter)
  })

  output$crowd_map <- renderLeaflet(crowd_map(filtered()))
}

shinyApp(ui, server)

input$park_filter can only be read inside a reactive context, which is why the filter lives inside reactive({}).

Beyond Theme Parks: The Same Pattern for Workforce Data

Nothing in Steps 1 through 6 is specific to theme parks. The pattern is the same for the labor data used in this course: find the endpoint, send the request, parse the JSON, reshape it, join it to other data, and visualize it.

  • The Data USA API, introduced in the course materials, returns federal datasets as JSON.
  • The Bureau of Labor Statistics (BLS) Public Data API returns labor statistics such as employment and unemployment series.

The lessons carry over too. Labor datasets often identify occupations and industries by names or codes that differ between sources, such as Standard Occupational Classification (SOC) and North American Industry Classification System (NAICS) codes. A join on mismatched names fails silently, exactly as the ride names did in Step 3. Counting the unmatched rows after every join catches it.

Wrap-Up

  • Search instead of hardcoding IDs. Filtering /v1/destinations by name survives ID changes.
  • Check every join. A join that matches nothing raises no error. Count the unmatched rows.
  • The API’s schema is not your analysis schema. The land grouping was the real work. Getting the data was one function call.
  • Pick the widget to fit the host. Crosstalk runs in static HTML. Shiny needs a server.

Full API documentation: https://api.themeparks.wiki/docs/v1

Packages, Sources, and Credits

Packages and documentation

Package Role in this code-through Documentation
httr2 Sends the API requests and reads the JSON that comes back https://httr2.r-lib.org
purrr Loops over lists of results (map_dfr, keep, pluck) https://purrr.tidyverse.org
stringr Cleans ride names (str_to_lower, str_replace_all, str_detect) https://stringr.tidyverse.org
leaflet Draws the interactive map over real map tiles https://rstudio.github.io/leaflet/
crosstalk Links the filter widgets to the map and table in static HTML https://rstudio.github.io/crosstalk/
DT Builds the searchable, filterable table https://rstudio.github.io/DT/
dplyr, tibble, knitr Data handling and table formatting https://dplyr.tidyverse.org

The pipe operator |> used throughout is part of base R (version 4.1 and later).

Data and map sources

  • Park data: ThemeParks.wiki API, documented at https://api.themeparks.wiki/docs/v1. Wait times are reported by the parks. They change constantly, and some rides post no wait at all.
  • Map tiles: Esri World Imagery (satellite) and CARTO Voyager (streets), with street data from OpenStreetMap contributors.
  • Land groupings: The API has no land field. The keyword list in Step 3 is my own approximate grouping and can be wrong near land borders.

Learn more

Generative AI use statement

I used Claude (Anthropic, 2026), a generative AI assistant, to help build this code-through. I worked with it in September and October 2026 at claude.ai.

What Claude did:

  • Suggested how to turn the API data into a map.
  • Found why my first land-matching join silently failed, after I knitted the document and shared the output with it.
  • Introduced the leaflet and crosstalk packages, which were new to me.

What I did:

  • Chose the topic and the ThemeParks.wiki API, and decided what the code-through should show.
  • Wrote the first draft of the R code and the R Markdown document.
  • Ran and knitted the document in RStudio and reviewed the output.
  • Found the first land-matching join fail
  • Asked for changes: a real map under the data instead of a scatter plot, working filter widgets, and notes that state what the simple setup cannot do.
  • Drafted explanatory text, including the commentary and limitation notes.
  • Reviewed the final document. I am responsible for it

The land groupings in Step 3 are approximate and may contain errors. I have noted the limits of the widgets in Step 6.

References

Anthropic. (2026). Claude [Large language model]. https://claude.ai

ThemeParks.wiki. (n.d.). ThemeParks.wiki API documentation. https://api.themeparks.wiki/docs/v1