For this script, it creates an interactive choropleth map that shows how aggravated assault incidents were distributed across Nashville-area ZIP Code Tabulation Areas (ZCTAs) during the summer of 2026. These totals are joined to Census ZIP code boundary data so that the map can display crime counts by area instead of by individual incident location.

This final map colors each ZIP code based on the number of aggravated assaults that occurred there, with darker red areas representing higher incident totals and lighter areas representing lower totals. Users can interact with the map by hovering over a ZIP code to see the ZIP code number and assault count, and clicking a ZIP code displays the same information in a popup window. This makes it easy to identify crime hotspots, compare assault levels across neighborhoods, and visualize where aggravated assaults were most concentrated during the study period.

The modified map is better than the original because it provides information immediately when the user moves their mouse over a ZIP code area. In the original map, users had to click each ZIP code to see the number of aggravated assaults. With the hover labels, the ZIP code and assault count appear instantly, making it much faster and easier to explore the data.

I used AI as a tool to help improve my R script and make the final map more effective. After creating the original choropleth map, I asked AI for suggestions on simple improvements that would add value without changing the overall purpose of the project. One suggestion I found useful was adding hover labels that display the ZIP code and aggravated assault count whenever a user moves their mouse over a ZIP code area.

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. Colors ZCTAs based on incident totals.
#
# Darker shades represent more incidents.
############################################################

############################################################
# Step 1: Install Required Packages
############################################################

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
############################################################

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

options(tigris_use_cache = TRUE)

library(tigris)

############################################################
# Step 3: Download Aggravated Assault Data
############################################################

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
############################################################

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
############################################################

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

############################################################
# Step 6: Download ZCTA Boundaries
############################################################

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

############################################################
# Step 7: Keep Tennessee ZCTAs
############################################################

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

############################################################
# Step 8: Match Coordinate Systems
############################################################

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

############################################################
# Step 9: Assign Each Incident to a ZCTA
############################################################

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

############################################################
# Step 10: Verify the Spatial Join
############################################################

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

############################################################
# Step 11: Count Incidents by ZCTA
############################################################

ZCTACounts <- CrimeWithZCTA |>
  st_drop_geometry() |>
  count(
    ZCTA5CE20,
    name = "Incidents"
  ) |>
  arrange(desc(Incidents))

ZCTACounts

############################################################
# Step 12: Keep Only ZCTAs Found in the Data
############################################################

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

############################################################
# Step 13: Join Incident Counts to ZCTAs
############################################################

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

############################################################
# Step 14: Create a Choropleth Color Palette
############################################################

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

############################################################
# Step 15: Create Popup Content
############################################################

ZCTAPopup <- ~paste0(
  "<strong>ZCTA:</strong> ",
  ZCTA5CE20,
  
  "<br><strong>Aggravated Assaults:</strong> ",
  Incidents
)

############################################################
# Step 16: Create the Choropleth Map
#
# NEW FEATURE:
# Hover over a ZIP code to display the ZIP code
# and incident count. ZIP codes also highlight
# when the mouse moves over them.
############################################################

ChoroplethMap <- leaflet(Nashville_ZCTAs) |>
  
  addProviderTiles("Esri.WorldStreetMap") |>
  
  addPolygons(
    fillColor = ~pal(Incidents),
    fillOpacity = 0.7,
    color = "black",
    weight = 1,
    popup = ZCTAPopup,
    
    label = ~paste0(
      "ZIP Code: ",
      ZCTA5CE20,
      "<br>Aggravated Assaults: ",
      Incidents
    ),
    
    highlightOptions = highlightOptions(
      weight = 3,
      color = "blue",
      bringToFront = TRUE
    )
  ) |>
  
  addLegend(
    position = "bottomright",
    pal = pal,
    values = ~Incidents,
    title = "Aggravated Assaults"
  )

ChoroplethMap