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.
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.
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./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")
| 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 |
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?")
| 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")
| 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.
/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.
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.
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.
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.
selectInput) captures what the user
wants.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({}).
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 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.
/v1/destinations by name survives ID changes.Full API documentation:
https://api.themeparks.wiki/docs/v1
| 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).
map_dfr.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:
leaflet and crosstalk
packages, which were new to me.What I did:
The land groupings in Step 3 are approximate and may contain errors. I have noted the limits of the widgets in Step 6.
Anthropic. (2026). Claude [Large language model]. https://claude.ai
ThemeParks.wiki. (n.d.). ThemeParks.wiki API documentation. https://api.themeparks.wiki/docs/v1