1. Set Up API Keys

tidycensus::census_api_key(Sys.getenv("CENSUS_API")) #Census API Key
google_api_key <- Sys.getenv("GOOGLE_API") #Google API

2. Download Census Block Group Polygons through the Census API

#Getting Census Block Group Boundary
bg <- suppressMessages(
  tidycensus::get_acs(geography = "block group", # or "block", "tract", "county", "state" etc.
                      state = "GA",
                      county = c("Gwinnett"),
                      variables = c(hhincome = 'B19013_001'),
                      year = 2023,
                      survey = "acs5", # we typically use American Community Survey 5-year estimates
                      geometry = TRUE, # we need an sf object
                      output = "wide") # wide vs. long
)

#City of Duluth boundary
duluth <- tigris::places('GA') %>% filter(NAME == 'Duluth')

#Get block groups intersecting with the City of Duluth boundary
bg_duluth <- bg[duluth,]
#Check if city of choice is too big for assignment
dim(bg_duluth) #dim: 31x5 (31 block groups)
## [1] 31  5

3. Divide the City into Small Areas & Prepare Google API locationRestriction Parameter Values for Each Area

3.1 Method: Using Census BGs

#Function: Get XY coordinates and radius
getXYRadius <- function(polygon, gcs_id, pcs_id){
  
  #Transform the CRS to PCS
  if (st_crs(polygon) != st_crs(pcs_id)){
    polygon <- polygon %>% st_transform(pcs_id)
  }
  
  #Get bounding box of a given polygon
  bb <- st_bbox(polygon)
  
  #Get XY coordinates of any one corner of the bounding box.
  bb_corner <- st_point(c(bb[1], bb[2])) %>% st_sfc(crs = pcs_id)
  
  #Get centroid of the bb
  bb_center <- bb %>% st_as_sfc() %>% st_centroid()
  
  #Get the distance between bb_center and bb_corner
  r <- st_distance(bb_corner, bb_center)
  
  #Convert the CRS of centroid to GCS
  bb_center <- bb_center %>% st_transform(gcs_id)
  
  #Get longitude and latitude
  xy <- bb_center %>% st_coordinates() %>% as.vector()
  
  lon_lat_radius <- data.frame(x = xy[1],
                               y = xy[2],
                               r = r)
  
  return(lon_lat_radius)
}

#Define EPSG codes for GCS (WGS 84) and PCS (WGS 84 / UTM zone 16N)
gcs_id <- 4326 #georgia's gcs ID: 4326
pcs_id <- 32616 #georgia's pcs ID: 32616

#Pre-allocate a data frame. Results will fill this data frame
bg_duluth_xyr <- data.frame(x = numeric(nrow(bg_duluth)),
                             y = NA,
                             r = NA)
#Do a for-loop
for (i in 1:nrow(bg_duluth)){
  bg_duluth_xyr[i,] <- bg_duluth[i, ] %>%
    getXYRadius(gcs_id = gcs_id,
                pcs_id = pcs_id)
}

3.2 Map an interactive map displaying (1) the city boundary and (2) the buffer objects with the tmap package

tmap_mode('view')
## โ„น tmap mode set to "view".
bg_duluth_xyr %>%
  #Convert the data frame into an sf object
  st_as_sf(coords = c("x", "y"), crs = st_crs(bg_duluth)) %>% 
  #Draw a buffer centered at the centroid of block group polygons, with buffer distance as the radius we calculated in bg_duluth_xyr
  st_buffer(dist = .$r) %>%
  #Display buffer
  tm_shape(.) + tm_polygons(alpha = 0.2, col = '#84a5db', border.col = '#84a5db', lwd = 1.2) +
  #Display the original Duluth block group polygon
  tm_shape(bg_duluth) + tm_borders(col= '#05327d', lwd = 1.5)
## 
## โ”€โ”€ tmap v3 code detected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
## [v3->v4] `tm_polygons()`: use 'fill' for the fill color of polygons/symbols
## (instead of 'col'), and 'col' for the outlines (instead of 'border.col').[v3->v4] `tm_polygons()`: use `fill_alpha` instead of `alpha`.

4. Collect POI Data using the Nearby Search in Google Places API

4.1 Custom API Function

nearbySearch <- function(lat, lon, radius, types_vec, fieldmask_vec, google_api_key){
  
  endpoint <- "https://places.googleapis.com/v1/places:searchNearby" #Nearby Search endpoint
  
  body <- list(
    includedTypes = as.list(types_vec),
    locationRestriction = list(
      circle = list(
        center = list(latitude = lat, longitude = lon),
        radius = radius
      )
    ),
    rankPreference = "DISTANCE" 
  )
  
  resp <- POST(
    endpoint,
    add_headers(
      "Content-Type" = "application/json",
      "X-Goog-Api-Key" = google_api_key,
      "X-Goog-FieldMask" = paste(fieldmask_vec, collapse = ",")),
    body = body,
    encode = "json"
  )
  
  data <- content(resp, as="text") %>% 
    jsonlite::fromJSON(flatten = T) %>% 
    as.data.frame()
  
  if (nrow(data) == 20){
    print("WARNING: The response has 20 rows! Consider using a smaller spatial unit.")
  }
  
  return(data)
}

4.2 Applying the Function to All Census Block Groups

#Pre-allocate list
data_list <- vector("list", nrow(bg_duluth_xyr))

for (i in seq_len(nrow(bg_duluth_xyr))) {
  
  data_list[[i]] <- nearbySearch(
    lat = bg_duluth_xyr$y[i],
    lon = bg_duluth_xyr$x[i],
    radius = bg_duluth_xyr$r[i],
    types_vec = c("cafe", "brunch_restaurant"),
    fieldmask_vec = c("places.id",
                      "places.displayName",
                      "places.formattedAddress",
                      "places.location",
                      "places.types",
                      "places.priceLevel",
                      "places.rating",
                      "places.userRatingCount"),
    google_api_key = Sys.getenv("GOOGLE_API")
  )
  Sys.sleep(1)
}

#Combine all data frames
data_all <- dplyr::bind_rows(data_list)

4.3 Saving the Data

saveRDS(data_all, here('duluth_google_poi_data.rds'))

5. Map the Collected POIs

#Read in the saved RDS file
poi <- read_rds("duluth_google_poi_data.rds")
#City of Duluth boundary
duluth <- tigris::places('GA') %>% filter(NAME == 'Duluth')
## Retrieving data for the year 2024
#Convert the data to an sf object using XY coordinates
poi_sf <- poi %>%
  rename(x = places.location.longitude, y = places.location.latitude) %>% 
  filter(!is.na(x) & !is.na(y)) %>%
  st_as_sf(coords = c("x", "y"), crs = 4326)

#Interactive map of both the POIs and the City of Duluth boundary
tm_shape(duluth) + 
  tm_borders(lwd = 1.3, col = "#545454") +
  tm_fill(alpha=0.4, col = "#bababa") + 
  tm_shape(poi_sf) + 
      tm_dots(col = "places.rating", title.col="Rating", breaks = c(2,3,4,5), border.col = "#545454", lwd = 1, shape = 21, palette = c("#e86464", "#edc15a", "#8fc989"),
              size = "places.userRatingCount", scale=2,
              popup.vars = c("Name" = "places.displayName.text",
                             "Price Level" = "places.priceLevel",
                             "Rating" = "places.rating",
                             "Rating Count" = "places.userRatingCount")) +
      tm_title("Interactive Map of Rating and Rating Count of Brunch Restaurants and Cafes in Duluth, GA")
## 
## โ”€โ”€ tmap v3 code detected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
## [v3->v4] `tm_polygons()`: use `fill_alpha` instead of `alpha`.
## [v3->v4] `tm_dots()`: migrate the argument(s) related to the legend of the
## visual variable `fill` namely 'title.col' (rename to 'title') to 'fill.legend =
## tm_legend(<HERE>)'
## [v3->v4] `tm_tm_dots()`: migrate the argument(s) related to the scale of the
## visual variable `size` namely 'scale' (rename to 'values.scale') to size.scale
## = tm_scale_continuous(<HERE>).
## โ„น For small multiples, specify a 'tm_scale_' for each multiple, and put them in
##   a list: 'size.scale = list(<scale1>, <scale2>, ...)'

6. Answer the following questions at the end of your script:

What city did you choose?

I chose the City of Duluth in Georgia.

Which two place types did you select?

The two place types I selected are cafes and brunch restaurants.

How many rows does your dataset contain?

There are 161 rows in my dataset.

Upon visual inspection, do you notice any spatial patterns in how POIs are distributed across the city?

Regarding spatial patterns, most cafes and brunch restaurants locate at the road-side of more major roads and they mostly cluster together.

Optional) Did you observe any other interesting findings?

I also noticed that many of the places with high user rating counts also have high ratings.