Submitted by Uday Bhanu Singh
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
)
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 |
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)
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"
)
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.
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.
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.
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.
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.
#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.
#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.
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.