Submitted by Uday Bhanu Singh

Setup

I selected Appleton, Wisconsin as my city. My aunt lives there and I have visited several times. It is a typical American small city, with a miniscule downtown and predominantly suburban layout. The city is not walkable at all and has high obesity rates.

Appleton is also peculiar in its demographics as there is a big college population in the center, while most of the city is family oriented. There is also a significant Hmong population in the city, among other minorities. For these reasons, I picked fast food restaurants and discount stores as my POIs in order to assess their spatial layout.

#Loading data for Appleton, Wisconsin saved from Mini 1. The two selected POIs are fast food restaurants and discount stores.

#Dropping rows with 0 ratings to remove any ghost entries. There were no rows removed, as every location had non-zero number of reviews.

appleton <- read_rds('appletonData.rds') |> #112 rows loaded
  distinct() |> #112 rows remain
  filter(!places.id %in% c("ChIJATKUxNNSAogRzu3DBGBeN2s", "ChIJbSW2M4WtA4gRsDZAGjTmaWY")) |> #Manually removing two erroneous entries. 110 rows remain.
  mutate(places.types = map_chr(places.types, ~ paste(.x, collapse = ", "))) |> #110 rows remain
  mutate(type = case_when(
    grepl('fast_food_restaurant', places.types) & grepl('discount_store', places.types) ~ "both",
    grepl('fast_food_restaurant', places.types) ~ "fast_food",
    grepl('discount_store', places.types) ~ "discount_store"
  )) |> 
  select(-'places.displayName.languageCode') |> 
  drop_na(places.userRatingCount, type) #110 rows remain

#Generating Appleton city limits boundary from tigris

appleton_bound <- places(
  state = "WI",
  year = 2024,
  class = 'sf'
) |> 
  filter(NAME == "Appleton")

#Giving the Appleton dataframe geometry based on coordinates
appleton_sf <- appleton |> 
  st_as_sf(
    coords = c("places.location.longitude", "places.location.latitude"),
    crs = 4269,
    remove = FALSE
  )

First 10 rows of the surrent state of the dataframe:

knitr::kable(head(sf::st_drop_geometry(appleton_sf), 10))
places.id places.types places.formattedAddress places.rating places.priceLevel places.userRatingCount places.location.latitude places.location.longitude places.displayName.text type
ChIJ9evwO4W3A4gRhXEZ-_99LME american_restaurant, ice_cream_shop, sandwich_shop, fast_food_restaurant, hamburger_restaurant, dessert_restaurant, dessert_shop, confectionery, food_store, store, restaurant, food, point_of_interest, establishment 850 W Evergreen Dr, Appleton, WI 54913, USA 4.5 PRICE_LEVEL_MODERATE 576 44.30234 -88.41832 Culver’s fast_food
ChIJwS-_Bgm3A4gRgIeRWt02Dvg fast_food_restaurant, deli, sandwich_shop, food_delivery, american_restaurant, meal_takeaway, food_store, store, restaurant, food, point_of_interest, establishment 715 W Evergreen Dr, Grand Chute, WI 54913, USA 4.0 PRICE_LEVEL_INEXPENSIVE 143 44.30153 -88.41725 Arby’s fast_food
ChIJr6jXcTG3A4gRZIBvqHN3ECg fast_food_restaurant, ice_cream_shop, hamburger_restaurant, dessert_shop, confectionery, american_restaurant, food_store, store, restaurant, food, point_of_interest, establishment 3950 N Richmond St, Appleton, WI 54913, USA 4.4 PRICE_LEVEL_INEXPENSIVE 202 44.30097 -88.41433 Tom’s Drive In fast_food
ChIJ7ZIlfgC3A4gRI_qZ0wn0_tU thrift_store, discount_store, store, association_or_organization, point_of_interest, service, establishment 1175 N Westhill Blvd, Appleton, WI 54914, USA 3.6 NA 28 44.27173 -88.46198 St. Vincent de Paul Appleton Westhill discount_store
ChIJW4HxoMu3A4gROJ65BFAMg2Q fast_food_restaurant, deli, sandwich_shop, food_delivery, american_restaurant, meal_takeaway, food_store, store, restaurant, food, point_of_interest, establishment 3801 W Wisconsin Ave, Appleton, WI 54914, USA 4.1 PRICE_LEVEL_INEXPENSIVE 926 44.27270 -88.46327 Arby’s fast_food
ChIJz5Dag8u3A4gRKsJeGBxLuUA fast_food_restaurant, hamburger_restaurant, american_restaurant, restaurant, food, point_of_interest, establishment 3815 W Wisconsin Ave, Appleton, WI 54914, USA 3.6 NA 1250 44.27269 -88.46430 Wendy’s fast_food
ChIJ9e81RAC3A4gRWWOYJ8pPLpI discount_store, store, point_of_interest, establishment 613 N Westhill Blvd, Appleton, WI 54914, USA 3.8 NA 65 44.26881 -88.46174 Frugal Freddie’s discount_store
ChIJM_NkRci3A4gRUxnBo2T8sj0 american_restaurant, ice_cream_shop, sandwich_shop, fast_food_restaurant, hamburger_restaurant, dessert_restaurant, dessert_shop, confectionery, food_store, store, restaurant, food, point_of_interest, establishment 599 N Westhill Blvd, Appleton, WI 54914, USA 4.3 PRICE_LEVEL_MODERATE 2374 44.26780 -88.46147 Culver’s fast_food
ChIJl_0aFy-3A4gREjYc6cnk5Qo thrift_store, discount_store, jewelry_store, electronics_store, clothing_store, finance, bicycle_store, sporting_goods_store, store, point_of_interest, service, establishment 500 N Westhill Blvd, Grand Chute, WI 54914, USA 4.9 NA 1252 44.26546 -88.46015 Pawn America discount_store
ChIJIRhqz863A4gRRiDKxY0L8Y0 clothing_store, department_store, womens_clothing_store, discount_store, jewelry_store, home_goods_store, store, point_of_interest, service, establishment 697 N Westhill Blvd, Grand Chute, WI 54914, USA 4.0 NA 425 44.26886 -88.46506 Burlington discount_store

Basic map of the Appleton boundary with the POIs plotted

tm_shape(appleton_bound) + tm_fill(col = "purple", lwd = 2, fill_alpha=0) +
  tm_shape(appleton_sf) + tm_symbols(col='type', palette = c('green', 'gold'),
                                        size=0.75, lwd = 0.75,
                                        hover = 'places.displayName.text', 
                                        labels = c('Discount Store', 'Fast Food', 'Missing')
                                        ) +
  tm_add_legend(type = "lines", labels = "Appleton City Boundary", col = "purple", lwd = 2) +
  tm_place_legends_right(width = 0.22)

Adding ACS data

For the purpose of this assignment, I did not restrict the study to official Appleton city limits. This is because Appleton’s city boundary is very strangely structured and contains multiple disjoint units.

Values for ACS metrics led to very skewed results after being clipped to the city limits, even after proportionally weighing them by area.

Therefore, all census blocks intersecting Appleton city limits, as well as any census blocks containing any POIs are used in their entirety.

counties <- c("Outagamie", "Calumet", "Winnebago") #Loading the three counties that the area covers.

#Loading a wide set of ACS data to explore:
acs_vars <- c(
  # 1. Income
  median_hh_income = "B19013_001",

  # 2. Poverty
  poverty_total    = "B17001_001",
  poverty_below    = "B17001_002",

  # 3. Housing
  occupied_housing = "B25003_001",
  owner_occupied   = "B25003_002",
  renter_occupied  = "B25003_003",
  median_rent      = "B25064_001",
  median_home_value = "B25077_001",

  # 4. Population
  total_population = "B01003_001",

  # 5. Commuting
  workers_16plus   = "B08301_001",
  drive_alone      = "B08301_003",
  public_transit   = "B08301_010",
  bicycle          = "B08301_018",
  walk             = "B08301_019",
  work_from_home   = "B08301_021",

  # 6. Race / ethnicity
  race_total       = "B03002_001",
  white_nonhispanic = "B03002_003",
  black_nonhispanic = "B03002_004",
  asian_nonhispanic = "B03002_006",
  hispanic          = "B03002_012"
)

#API calls:
acs_data <- map_dfr(
  counties,
  \(county_name) {
    get_acs(
      geography = "block group",
      variables = acs_vars,
      state = "WI",
      county = county_name,
      year = 2024,
      survey = "acs5",
      geometry = TRUE,
      output = "wide"
    )
  }
)

#Converting data into percentages, where appropriate for the purpose of analysis:
acs_data <- acs_data |>
  mutate(
  # Poverty
  poverty_pct = round(poverty_belowE / poverty_totalE * 100, 1),

  # Housing
  renter_pct = round(renter_occupiedE / occupied_housingE * 100, 1),
  owner_pct  = round(owner_occupiedE / occupied_housingE * 100, 1),

  # Commute mode
  drive_alone_pct = round(drive_aloneE / workers_16plusE * 100, 1),
  transit_pct     = round(public_transitE / workers_16plusE * 100, 1),
  bike_pct        = round(bicycleE / workers_16plusE * 100, 1),
  walk_pct        = round(walkE / workers_16plusE * 100, 1),
  wfh_pct         = round(work_from_homeE / workers_16plusE * 100, 1),

  # Race / ethnicity
  white_nonhispanic_pct = round(white_nonhispanicE / race_totalE * 100, 1),
  black_nonhispanic_pct = round(black_nonhispanicE / race_totalE * 100, 1),
  asian_nonhispanic_pct = round(asian_nonhispanicE / race_totalE * 100, 1),
  hispanic_pct          = round(hispanicE / race_totalE * 100, 1),

  # Convenient overall measure
  minority_pct = round(100 - white_nonhispanic_pct, 1)
  ) |>
    
  #Streamlining column titles and filtering relevant data:
  select(
    GEOID,
    median_hh_income = median_hh_incomeE,
    median_rent = median_rentE,
    median_home_value = median_home_valueE,
    total_population = total_populationE,

    poverty_pct,
    renter_pct,
    owner_pct,

    drive_alone_pct,
    transit_pct,
    bike_pct,
    walk_pct,
    wfh_pct,

    white_nonhispanic_pct,
    black_nonhispanic_pct,
    asian_nonhispanic_pct,
    hispanic_pct,
    minority_pct,

    geometry
  )
#Reprojecting into same CRS as POIs
acs_data <- st_transform(acs_data, st_crs(appleton_sf))

#Loading all census blocks intersecting with Appleton official city limits
acs_data_block <- st_filter(
  acs_data,
  appleton_bound,
  .predicate = st_intersects
)

#Loading all census blocks intersecting with any POIs in the data
appleton_test <- st_filter(
          acs_data,
          appleton_sf,
          .predicate = st_intersects)

#Joining the two sets of census blocks by assigning the union to appleton_acs
appleton_acs <- bind_rows(appleton_test, acs_data_block) |> 
  distinct(GEOID, .keep_all = TRUE)

#Creating a new column to keep store the number of POIs in each block
appleton_acs$poi_count <- lengths(
  st_intersects(appleton_acs, appleton_sf)
)
#Setting up variables to plug into each map to display the same information as popups
poi_popup_vars = c("Name: " = 'places.displayName.text', "Rating: " = 'places.rating', "Price Level: " = 'places.priceLevel')
block_popup_vars_base = c(
  "Population: " = 'total_population',
  "Median HH Income: " = 'median_hh_income',
  "Median Rent: " = "median_rent",
  "Median Home Val.:" = "median_home_value",
  "% Minority: " = "minority_pct",
  "# of POIs" = "poi_count"
)

Analysis

Median Household Income:

tm_shape(appleton_acs) + tm_polygons(fill='median_hh_income', 
                                        fill.scale = tm_scale_continuous(values = "viridis"),
                                        popup = tm_popup(vars = block_popup_vars_base),
                                        fill.legend = tm_legend("Median Household Income ($)"),
                                        fill_alpha = 0.5) +
tm_shape(appleton_bound) + tm_fill(col = "purple", lwd = 2, fill_alpha=0) +
  tm_shape(appleton_sf) + tm_symbols(col='type', palette = c('green', 'gold'),
                                        size=0.75, lwd = 0.75,
                                        hover = 'places.displayName.text', 
                                        labels = c('Discount Store', 'Fast Food', 'Missing'),
                                        popup = tm_popup(vars = poi_popup_vars)
                                        ) +
  tm_add_legend(type = "lines", labels = "Appleton City Boundary", col = "purple", lwd = 2) +
  tm_place_legends_right(width = 0.22)

I first wanted to investigate the spatial distribution of the two types of stores with respect to median income. Fast food restaurants and discount stores appear across block groups with a wide range of median household incomes. The map does not show a clear concentration of either POI type exclusively in lower- or higher-income areas, suggesting that household income alone is not a strong predictor of where these establishments are located.

Minority Population Percentage

var = 'minority_pct'

tm_shape(appleton_acs) + tm_polygons(fill=var,
                                        fill.scale = tm_scale_continuous(values = "reds"),
                                        fill.legend = tm_legend("Minority Population (%)"),
                                        fill_alpha = 0.5,
                                        popup = tm_popup(vars = c(block_popup_vars_base, setNames(var, var)))
                                     ) +
  tm_shape(appleton_bound) + tm_fill(col = "purple", lwd = 2, fill_alpha=0, popup = tm_popup(vars = FALSE)) +
  tm_shape(appleton_sf) + 
  tm_shape(appleton_sf |> filter(type == "discount_store")) +
  tm_symbols(
    fill = "places.rating",
    fill.scale = tm_scale_continuous(values = "greens", limits = c(1, 5)),
    fill.legend = tm_legend("Discount Store Rating"),
    col = "black",
    size = 0.75,
    lwd = 0.75,
    hover = "places.displayName.text",
    popup = tm_popup(vars = poi_popup_vars)
  ) +

  tm_shape(appleton_sf |> filter(type == "fast_food")) +
    tm_symbols(
      fill = "places.rating",
      fill.scale = tm_scale_continuous(values = "oranges", limits = c(1, 5)),
      fill.legend = tm_legend("Fast Food Rating"),
      col = "black",
      size = 0.75,
      lwd = 0.75,
      hover = "places.displayName.text",
      popup = tm_popup(vars = poi_popup_vars)
    ) +
  tm_add_legend(type = "lines", labels = "Appleton City Boundary", col = "purple", lwd = 2) +
  tm_place_legends_right(width = 0.22)

From personal experience, I perceive Appleton as being quite segregated. Hence, I wanted to invenstigate whether the placement of these stores shares any correlation with where most minority groups are located. POIs seem to occur across block groups with varying minority population shares, with no clear relationship between minority share and POI location or rating. Some clustering occurs in more diverse block groups near central Appleton, but similar POI concentrations also appear in areas with lower minority shares. So there is still no clear insight gained.

Median Home Value

var = 'median_home_value'

tm_shape(appleton_acs) + tm_polygons(fill=var,
                                        fill.scale = tm_scale_continuous(values = "blues"),
                                        fill.legend = tm_legend("Median Home Value ($)"),
                                        fill_alpha = 0.5,
                                        popup = tm_popup(vars = c(block_popup_vars_base, setNames(var, var)))
                                     ) +
  tm_shape(appleton_bound) + tm_fill(col = "purple", lwd = 2, fill_alpha=0, popup = tm_popup(vars = FALSE)) +
  tm_shape(appleton_sf) + 
  tm_shape(appleton_sf |> filter(type == "discount_store")) +
  tm_symbols(
    fill = "places.rating",
    fill.scale = tm_scale_continuous(values = "greens", limits = c(1, 5)),
    fill.legend = tm_legend("Discount Store Rating"),
    col = "black",
    size = 0.75,
    lwd = 0.75,
    hover = "places.displayName.text",
    popup = tm_popup(vars = poi_popup_vars)
  ) +

  tm_shape(appleton_sf |> filter(type == "fast_food")) +
    tm_symbols(
      fill = "places.rating",
      fill.scale = tm_scale_continuous(values = "oranges", limits = c(1, 5)),
      fill.legend = tm_legend("Fast Food Rating"),
      col = "black",
      size = 0.75,
      lwd = 0.75,
      hover = "places.displayName.text",
      popup = tm_popup(vars = poi_popup_vars)
    ) +
  tm_add_legend(type = "lines", labels = "Appleton City Boundary", col = "purple", lwd = 2) +
  tm_place_legends_right(width = 0.22)

I thought that median home values may be a stronger indicator of the location of these stores, as Appleton is a very car-dependant city. POIs are found across block groups with a broad range of median home values. There does seem to be somewhat of a clustering in the downtown area, where property values are generally lower. However, the correlation is not really consistent.

Asian Population

var = 'asian_nonhispanic_pct'

tm_shape(appleton_acs) + tm_polygons(fill=var,
                                        fill.scale = tm_scale_continuous(values = "reds"),
                                        fill.legend = tm_legend("Asian, Non-Hispanic Population (%)"),
                                        fill_alpha = 0.5,
                                        popup = tm_popup(vars = c(block_popup_vars_base, setNames(var, var)))
                                     ) +
  tm_shape(appleton_bound) + tm_fill(col = "purple", lwd = 2, fill_alpha=0, popup = tm_popup(vars = FALSE)) +
  tm_shape(appleton_sf) + 
  tm_shape(appleton_sf |> filter(type == "discount_store")) +
  tm_symbols(
    fill = "places.rating",
    fill.scale = tm_scale_continuous(values = "greens", limits = c(1, 5)),
    fill.legend = tm_legend("Discount Store Rating"),
    col = "black",
    size = 0.75,
    lwd = 0.75,
    hover = "places.displayName.text",
    popup = tm_popup(vars = poi_popup_vars)
  ) +

  tm_shape(appleton_sf |> filter(type == "fast_food")) +
    tm_symbols(
      fill = "places.rating",
      fill.scale = tm_scale_continuous(values = "oranges", limits = c(1, 5)),
      fill.legend = tm_legend("Fast Food Rating"),
      col = "black",
      size = 0.75,
      lwd = 0.75,
      hover = "places.displayName.text",
      popup = tm_popup(vars = poi_popup_vars)
    ) +
  tm_add_legend(type = "lines", labels = "Appleton City Boundary", col = "purple", lwd = 2) +
  tm_place_legends_right(width = 0.22)

Given the significant Hmong population in the city, I wanted to see if there may be a correlation with the POIs. However, similar to the last maps, the correlation is weak and sporadic.

Racial and Ethnic Composition

appleton_acs |>
  st_drop_geometry() |>
  pivot_longer(
    cols = c(
      white_nonhispanic_pct,
      black_nonhispanic_pct,
      asian_nonhispanic_pct,
      hispanic_pct
    ),
    names_to = "race",
    values_to = "race_pct"
  ) |>
  mutate(
    race = recode(
      race,
      white_nonhispanic_pct = "White, non-Hispanic",
      black_nonhispanic_pct = "Black, non-Hispanic",
      asian_nonhispanic_pct = "Asian, non-Hispanic",
      hispanic_pct = "Hispanic/Latino"
    )
  ) |> #Plotting 4 scatterplots
  ggplot(aes(x = race_pct, y = poi_count)) +
  geom_point(alpha = 0.5) +
  geom_smooth(method = "lm", se = TRUE) +
  facet_wrap(~ race, scales = "free_x") +
  labs(
    x = "Share of Block Group Population (%)",
    y = "Number of POIs"
  ) +
  theme_minimal()

Looking at the four-major demographic in parallel. POI counts show little apparent relationship with block-group racial and ethnic composition. The fitted trends are relatively flat across all four groups, while a small number of block groups with unusually high POI counts appear to drive some of the variation.

Characteristics of Block Groups with POIs

#Creating another column containing boolean values for whether or not a tract has at least one POI
appleton_acs <- appleton_acs |>
  mutate(
    has_poi = poi_count > 0
  )

appleton_acs |>
  st_drop_geometry() |>
  select(
    has_poi,
    median_hh_income,
    renter_pct,
    minority_pct,
    median_home_value
  ) |>
  pivot_longer(
    cols = -has_poi,
    names_to = "variable",
    values_to = "value"
  ) |>
  mutate(
    variable = recode(
      variable,
      median_hh_income = "Median Household Income",
      poverty_pct = "Poverty (%)",
      renter_pct = "Renter-Occupied (%)",
      walk_pct = "Walk to Work (%)",
      minority_pct = "Minority Population (%)",
      median_home_value = "Median Home Value"
    ),
    has_poi = if_else(
      has_poi,
      "POI Present",
      "No POI"
    )
  ) |>
  ggplot(aes(x = has_poi, y = value, fill = has_poi)) +
  geom_boxplot(alpha = 0.7, outlier.alpha = 0.5) +
  facet_wrap(
    ~ variable,
    scales = "free_y",
    ncol = 2
  ) +
  labs(
    x = NULL,
    y = NULL,
    fill = NULL
  ) +
  theme_minimal() +
  theme(
    legend.position = "none",
    strip.text = element_text(face = "bold")
  )

Assessing the variables explored earlier via boxplots instead of maps. Block groups containing at least one POI tend to have higher renter-occupied and minority population shares than those without POIs. Differences in median household income and home values are less pronounced, with substantial overlap between the two groups.

Distance from Downtown

#Entering coordinates of the city center (taken from Google Maps)
downtown <- st_sfc(
  st_point(c(-88.407272, 44.261813)),
  crs = 4326
)

#Converting to projected crs for distance calculation
appleton_sf_proj <- st_transform(appleton_sf, 3071)
downtown_proj <- st_transform(downtown, 3071)
#Calculating distances from downtown
appleton_sf$dist_downtown_km <- as.numeric(
  st_distance(
    appleton_sf_proj,
    downtown_proj
  )
) / 1000

ggplot(
  st_drop_geometry(appleton_sf),
  aes(
    x = dist_downtown_km,
    fill = type,
    color = type
  )
) +
  geom_density(
    alpha = 0.3,
    linewidth = 1
  ) +
  scale_fill_manual(
    values = c("discount_store" = "green", "fast_food" = "gold"),
    labels = c("Discount Store", "Fast Food")
  ) +
  scale_color_manual(
    values = c("discount_store" = "green", "fast_food" = "gold"),
    labels = c("Discount Store", "Fast Food")
  ) +
  labs(
    x = "Distance from Downtown (km)",
    y = "Density",
    fill = "POI Type",
    color = "POI Type"
  ) +
  theme_minimal()

The two POI types show different spatial distributions relative to downtown Appleton (taken as a point in the middle of the main city street.) Discount stores are more concentrated closer to downtown, while fast food restaurants peak farther away, around 4-5 km. Both types overlap substantially, but fast food also shows a more pronounced presence at greater distances from the city center.

Conclusion

Overall, fast food restaurants and discount stores are unevenly distributed across the Appleton area, with noticeable clustering along particular corridors. Neighborhood socioeconomic characteristics show some minor differences between areas with and without POIs but most relationships are weak.

POI type appears to have a clearer relationship with distance from downtown. The city’s layout, at least with respect to the two POIs, seems to be uniformly dominated by car-centric design and broad commercial patterns rather than by demographic patterns.