Choropleth Map

I modified the original choropleth map by adding the percentage of total aggravated assaults that occurred within each ZCTA. The original map showed the number of aggravated assaults in each area and used darker colors to represent higher incident counts. The modified map keeps this information but adds more context by showing what percentage of all mapped aggravated assaults occurred within each ZCTA when the area is clicked. This makes it easier to understand how much each area contributes to the overall number of aggravated assaults in the dataset.

How the Modification Works

The main modifications to the original lesson code are found in Step 11 and Step 15. In the original Step 11, the code counts the number of aggravated assaults within each ZCTA. I modified this step by adding a Percent_of_Total variable, which divides the number of incidents in each ZCTA by the total number of incidents and multiplies the result by 100. In Step 15, I modified the original popup content to include this new percentage in addition to the ZCTA and number of aggravated assaults. I also used round() to display the percentage to one decimal place. Together, these changes allow users to click on a ZCTA and see both its number of aggravated assaults and its share of the total aggravated assaults represented in the map.

How I Used AI

I used AI to brainstorm ways to make the original choropleth map more informative. Some of the suggestions included adding hover labels, adding interactive layer controls, and showing each ZCTA’s percentage of the total aggravated assaults. I chose the percentage suggestion because it added context to the existing incident counts without making the map overly complicated. I also used AI to help develop the code for calculating the percentage and adding it to the popup. I decided not to use the interactive layer control suggestion because I felt that being able to turn the crime layer on and off would not add as much useful information as showing each area’s share of the total incidents.

Code

############################################################
# Lesson 2: Mapping Crime by Area with a Choropleth Map
#
# This script creates a choropleth map showing how
# aggravated assaults are distributed across ZIP Code
# Tabulation Areas (ZCTAs).
#
# Instead of mapping individual incidents, this lesson:
#
# 1. Assigns each incident to a ZCTA.
# 2. Counts incidents within each ZCTA.
# 3. Calculates each ZCTA's share of total incidents.
# 4. Colors ZCTAs based on incident totals.
#
# Darker shades represent more incidents.
############################################################


############################################################
# Step 1: Install Required Packages
#
# Check whether the required packages are installed.
# Install any that are missing.
############################################################

required_packages <- c(
  "jsonlite",
  "tidyverse",
  "sf",
  "leaflet",
  "tigris"
)

installed_packages <- rownames(installed.packages())

for (pkg in required_packages) {
  if (!pkg %in% installed_packages) {
    install.packages(pkg)
  }
}


############################################################
# Step 2: Load Required Packages
#
# jsonlite  = download data from the API
# tidyverse = clean and summarize data
# sf        = perform spatial analysis
# leaflet   = create interactive maps
# tigris    = download Census boundaries
############################################################

library(jsonlite)
library(tidyverse)
library(sf)
library(leaflet)

# Store Census downloads locally so they do not need
# to be downloaded every time the script is run.

options(tigris_use_cache = TRUE)

library(tigris)


############################################################
# Step 3: Download Aggravated Assault Data
#
# Retrieve Summer 2026 aggravated assault incidents
# (NIBRS code 13A) from Nashville's crime API.
############################################################

base_url <- paste0(
  "https://services2.arcgis.com/HdTo6HJqh92wn4D8/",
  "arcgis/rest/services/",
  "Metro_Nashville_Police_Department_Incidents_view/",
  "FeatureServer/0/query"
)

query <- paste(
  "Incident_Occurred >= DATE '2026-06-01'",
  "AND Incident_Occurred < DATE '2026-09-01'",
  "AND Offense_NIBRS = '13A'"
)

crime_url <- paste0(
  base_url,
  "?where=",
  URLencode(query, reserved = TRUE),
  "&outFields=*",
  "&f=json"
)

CrimeData <- fromJSON(
  crime_url
)$features$attributes


############################################################
# Step 4: Prepare the Data
#
# Convert dates into a readable format and remove
# records that are missing coordinates.
############################################################

CrimeData <- CrimeData |>
  mutate(
    Incident_Occurred = as.POSIXct(
      Incident_Occurred / 1000,
      origin = "1970-01-01",
      tz = "America/Chicago"
    )
  ) |>
  filter(
    !is.na(Longitude),
    !is.na(Latitude)
  )

cat(
  "Number of aggravated assault incidents:",
  nrow(CrimeData),
  "\n"
)


############################################################
# Step 5: Convert Incidents into a Spatial Layer
#
# Unlike Lesson 1, this lesson requires spatial
# analysis. The sf package converts latitude and
# longitude values into geographic points that can
# participate in GIS operations.
############################################################

CrimeData_sf <- st_as_sf(
  CrimeData,
  coords = c("Longitude", "Latitude"),
  crs = 4326,
  remove = FALSE
)


############################################################
# Step 6: Download ZCTA Boundaries
#
# Download ZIP Code Tabulation Area (ZCTA)
# boundaries from the U.S. Census Bureau.
############################################################

TN_ZCTAs <- zctas(
  cb = TRUE,
  year = 2020
)


############################################################
# Step 7: Keep Tennessee ZCTAs
#
# Tennessee ZIP codes generally begin with "37".
# Keep only ZCTAs that are likely to be located
# within Tennessee.
############################################################

TN_ZCTAs <- TN_ZCTAs |>
  filter(
    startsWith(ZCTA5CE20, "37")
  )


############################################################
# Step 8: Match Coordinate Systems
#
# Spatial layers must use the same coordinate
# reference system (CRS) before they can be
# combined through a spatial join.
############################################################

TN_ZCTAs <- st_transform(
  TN_ZCTAs,
  st_crs(CrimeData_sf)
)


############################################################
# Step 9: Assign Each Incident to a ZCTA
#
# This spatial join determines which ZCTA contains
# each crime incident.
#
# Conceptually:
#
# Point
#   ↓
# Falls inside
#   ↓
# Polygon
############################################################

CrimeWithZCTA <- st_join(
  CrimeData_sf,
  TN_ZCTAs |>
    select(ZCTA5CE20)
)


############################################################
# Step 10: Verify the Spatial Join
#
# Confirm that incidents were successfully assigned
# to ZCTAs.
############################################################

CrimeWithZCTA |>
  st_drop_geometry() |>
  count(is.na(ZCTA5CE20))


############################################################
# Step 11: Count Incidents by ZCTA and Calculate
# Each ZCTA's Share of Total Incidents
#
# Aggregate individual incidents into area-level
# totals and calculate the percentage of all mapped
# aggravated assaults occurring in each ZCTA.
#
# This transforms:
#
# One row = One incident
#
# into:
#
# One row = One ZCTA
############################################################

ZCTACounts <- CrimeWithZCTA |>
  st_drop_geometry() |>
  count(
    ZCTA5CE20,
    name = "Incidents"
  ) |>
  mutate(
    Percent_of_Total = Incidents / sum(Incidents) * 100
  ) |>
  arrange(desc(Incidents))

ZCTACounts


############################################################
# Step 12: Keep Only ZCTAs Found in the Data
#
# Retain only the ZCTAs containing one or more
# aggravated assault incidents.
############################################################

Nashville_ZCTAs <- TN_ZCTAs |>
  filter(
    !is.na(ZCTA5CE20),
    ZCTA5CE20 %in% unique(ZCTACounts$ZCTA5CE20)
  )


############################################################
# Step 13: Join Incident Counts to ZCTAs
#
# Attach the incident totals and percentages to the
# corresponding ZCTA polygons.
############################################################

Nashville_ZCTAs <- Nashville_ZCTAs |>
  left_join(
    ZCTACounts,
    by = "ZCTA5CE20"
  )


############################################################
# Step 14: Create a Choropleth Color Palette
#
# Choropleth maps use color to represent numerical
# values.
#
# Lighter colors = fewer incidents
# Darker colors  = more incidents
############################################################

pal <- colorNumeric(
  palette = "Reds",
  domain = Nashville_ZCTAs$Incidents
)


############################################################
# Step 15: Create Popup Content
#
# Display the ZCTA, number of aggravated assaults,
# and share of total assaults when users click
# a polygon.
############################################################

ZCTAPopup <- ~paste0(
  "<strong>ZCTA:</strong> ",
  ZCTA5CE20,
  
  "<br><strong>Aggravated Assaults:</strong> ",
  Incidents,
  
  "<br><strong>Share of Total Assaults:</strong> ",
  round(Percent_of_Total, 1),
  "%"
)


############################################################
# Step 16: Create the Choropleth Map
#
# Polygon Color = Number of Incidents
#
# This map highlights which ZIP code areas have
# the greatest concentrations of aggravated assaults.
############################################################

ChoroplethMap <- leaflet(Nashville_ZCTAs) |>
  
  addProviderTiles("Esri.WorldStreetMap") |>
  
  addPolygons(
    fillColor = ~pal(Incidents),
    fillOpacity = 0.7,
    color = "black",
    weight = 1,
    popup = ZCTAPopup
  ) |>
  
  addLegend(
    position = "bottomright",
    pal = pal,
    values = ~Incidents,
    title = "Aggravated Assaults"
  )

ChoroplethMap