For this code, I made three changes to the original choropleth map to make it more interactive and informative. First, I added hover information so viewers can quickly see the ZIP code and number of aggravated assaults without having to click each area. I also added a highlighting feature that makes the ZIP code you are looking at stand out from the surrounding areas.
The biggest change was allowing viewers to look at aggravated assaults by time of day. I divided the incidents into morning, afternoon, evening, and overnight categories. The code uses the time each incident occurred to place it into one of those categories, then counts the incidents in each ZIP code. Viewers can switch between the total number of assaults and the different times of day. I kept the same color scale across the map so the different views can be compared.Â
The revised map is more useful because viewers can see not only where aggravated assaults were concentrated but also how those patterns changed by time of day.
I used AI to brainstorm possible modifications and help develop the code. One suggestion I used was to break the data down by time of day, which added another way to explore the information. AI also suggested displaying ZIP codes directly on the map. I tried it but thought it made the map too cluttered, so I kept the ZIP codes as hover information instead.Â
CODE
############################################################
# Lesson 2: Mapping Crime by Area with a Choropleth Map
#
# This script creates an interactive choropleth map showing
# how aggravated assaults are distributed across ZIP Code
# Tabulation Areas (ZCTAs).
#
# Modifications:
#
# 1. Add hover information showing the ZIP code and number
# of aggravated assaults.
#
# 2. Highlight ZCTA polygons when users hover over them.
#
# 3. Add a layer control that allows viewers to switch
# between total aggravated assaults and assaults by
# time of day.
############################################################
############################################################
# 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
#
# 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: Create Time-of-Day Categories
#
# Divide incidents into four time periods based on
# the hour when each incident occurred.
#
# Morning = 6:00 a.m. to 11:59 a.m.
# Afternoon = Noon to 5:59 p.m.
# Evening = 6:00 p.m. to 11:59 p.m.
# Overnight = Midnight to 5:59 a.m.
############################################################
CrimeData <- CrimeData |>
mutate(
Hour = as.integer(format(Incident_Occurred, "%H")),
TimeOfDay = case_when(
Hour >= 6 & Hour < 12 ~ "Morning",
Hour >= 12 & Hour < 18 ~ "Afternoon",
Hour >= 18 & Hour < 24 ~ "Evening",
TRUE ~ "Overnight"
)
)
############################################################
# Step 6: Convert Incidents into a Spatial Layer
############################################################
CrimeData_sf <- st_as_sf(
CrimeData,
coords = c("Longitude", "Latitude"),
crs = 4326,
remove = FALSE
)
############################################################
# Step 7: Download ZCTA Boundaries
############################################################
TN_ZCTAs <- zctas(
cb = TRUE,
year = 2020
)
############################################################
# Step 8: Keep Tennessee ZCTAs
############################################################
TN_ZCTAs <- TN_ZCTAs |>
filter(
startsWith(ZCTA5CE20, "37")
)
############################################################
# Step 9: Match Coordinate Systems
############################################################
TN_ZCTAs <- st_transform(
TN_ZCTAs,
st_crs(CrimeData_sf)
)
############################################################
# Step 10: Assign Each Incident to a ZCTA
############################################################
CrimeWithZCTA <- st_join(
CrimeData_sf,
TN_ZCTAs |>
select(ZCTA5CE20)
)
############################################################
# Step 11: Verify the Spatial Join
############################################################
CrimeWithZCTA |>
st_drop_geometry() |>
count(is.na(ZCTA5CE20))
############################################################
# Step 12: Count Total Incidents by ZCTA
############################################################
ZCTACounts <- CrimeWithZCTA |>
st_drop_geometry() |>
filter(!is.na(ZCTA5CE20)) |>
count(
ZCTA5CE20,
name = "Incidents"
) |>
arrange(desc(Incidents))
ZCTACounts
############################################################
# Step 13: Count Incidents by ZCTA and Time of Day
#
# Create separate counts for morning, afternoon,
# evening and overnight incidents.
############################################################
TimeCounts <- CrimeWithZCTA |>
st_drop_geometry() |>
filter(!is.na(ZCTA5CE20)) |>
count(
ZCTA5CE20,
TimeOfDay,
name = "Incidents"
)
TimeCounts
############################################################
# Step 14: Keep Only ZCTAs Found in the Crime Data
############################################################
Nashville_ZCTAs <- TN_ZCTAs |>
filter(
!is.na(ZCTA5CE20),
ZCTA5CE20 %in% unique(ZCTACounts$ZCTA5CE20)
)
############################################################
# Step 15: Create Total Incident Map Data
############################################################
Total_ZCTAs <- Nashville_ZCTAs |>
left_join(
ZCTACounts,
by = "ZCTA5CE20"
) |>
mutate(
Incidents = replace_na(Incidents, 0)
)
############################################################
# Step 16: Create Morning Map Data
############################################################
Morning_ZCTAs <- Nashville_ZCTAs |>
left_join(
TimeCounts |>
filter(TimeOfDay == "Morning") |>
select(ZCTA5CE20, Incidents),
by = "ZCTA5CE20"
) |>
mutate(
Incidents = replace_na(Incidents, 0)
)
############################################################
# Step 17: Create Afternoon Map Data
############################################################
Afternoon_ZCTAs <- Nashville_ZCTAs |>
left_join(
TimeCounts |>
filter(TimeOfDay == "Afternoon") |>
select(ZCTA5CE20, Incidents),
by = "ZCTA5CE20"
) |>
mutate(
Incidents = replace_na(Incidents, 0)
)
############################################################
# Step 18: Create Evening Map Data
############################################################
Evening_ZCTAs <- Nashville_ZCTAs |>
left_join(
TimeCounts |>
filter(TimeOfDay == "Evening") |>
select(ZCTA5CE20, Incidents),
by = "ZCTA5CE20"
) |>
mutate(
Incidents = replace_na(Incidents, 0)
)
############################################################
# Step 19: Create Overnight Map Data
############################################################
Overnight_ZCTAs <- Nashville_ZCTAs |>
left_join(
TimeCounts |>
filter(TimeOfDay == "Overnight") |>
select(ZCTA5CE20, Incidents),
by = "ZCTA5CE20"
) |>
mutate(
Incidents = replace_na(Incidents, 0)
)
############################################################
# Step 20: Create Choropleth Color Palette
#
# Keep the original red color palette.
#
# IMPORTANT:
# All layers use the same scale based on the largest
# total incident count. This makes the colors comparable
# when viewers switch between layers.
############################################################
pal <- colorNumeric(
palette = "Reds",
domain = c(
0,
max(Total_ZCTAs$Incidents, na.rm = TRUE)
)
)
############################################################
# Step 21: Create the Interactive Choropleth Map
#
# MODIFICATION 1:
# Hover labels show ZIP code and incident count.
#
# MODIFICATION 2:
# ZIP code polygons are highlighted when hovered over.
#
# MODIFICATION 3:
# Viewers can switch between total incidents and
# incidents occurring during different times of day.
############################################################
ChoroplethMap <- leaflet() |>
addProviderTiles("Esri.WorldStreetMap") |>
##########################################################
# TOTAL AGGRAVATED ASSAULTS
##########################################################
addPolygons(
data = Total_ZCTAs,
group = "Total Aggravated Assaults",
fillColor = ~pal(Incidents),
fillOpacity = 0.7,
color = "black",
weight = 1,
label = ~paste0(
"ZIP Code: ",
ZCTA5CE20,
" | Total Aggravated Assaults: ",
Incidents
),
popup = ~paste0(
"<strong>ZIP Code:</strong> ",
ZCTA5CE20,
"<br><strong>Total Aggravated Assaults:</strong> ",
Incidents
),
highlightOptions = highlightOptions(
weight = 3,
color = "white",
fillOpacity = 0.9,
bringToFront = TRUE
)
) |>
##########################################################
# MORNING
##########################################################
addPolygons(
data = Morning_ZCTAs,
group = "Morning (6 AM - Noon)",
fillColor = ~pal(Incidents),
fillOpacity = 0.7,
color = "black",
weight = 1,
label = ~paste0(
"ZIP Code: ",
ZCTA5CE20,
" | Morning Assaults: ",
Incidents
),
popup = ~paste0(
"<strong>ZIP Code:</strong> ",
ZCTA5CE20,
"<br><strong>Morning Assaults:</strong> ",
Incidents
),
highlightOptions = highlightOptions(
weight = 3,
color = "white",
fillOpacity = 0.9,
bringToFront = TRUE
)
) |>
##########################################################
# AFTERNOON
##########################################################
addPolygons(
data = Afternoon_ZCTAs,
group = "Afternoon (Noon - 6 PM)",
fillColor = ~pal(Incidents),
fillOpacity = 0.7,
color = "black",
weight = 1,
label = ~paste0(
"ZIP Code: ",
ZCTA5CE20,
" | Afternoon Assaults: ",
Incidents
),
popup = ~paste0(
"<strong>ZIP Code:</strong> ",
ZCTA5CE20,
"<br><strong>Afternoon Assaults:</strong> ",
Incidents
),
highlightOptions = highlightOptions(
weight = 3,
color = "white",
fillOpacity = 0.9,
bringToFront = TRUE
)
) |>
##########################################################
# EVENING
##########################################################
addPolygons(
data = Evening_ZCTAs,
group = "Evening (6 PM - Midnight)",
fillColor = ~pal(Incidents),
fillOpacity = 0.7,
color = "black",
weight = 1,
label = ~paste0(
"ZIP Code: ",
ZCTA5CE20,
" | Evening Assaults: ",
Incidents
),
popup = ~paste0(
"<strong>ZIP Code:</strong> ",
ZCTA5CE20,
"<br><strong>Evening Assaults:</strong> ",
Incidents
),
highlightOptions = highlightOptions(
weight = 3,
color = "white",
fillOpacity = 0.9,
bringToFront = TRUE
)
) |>
##########################################################
# OVERNIGHT
##########################################################
addPolygons(
data = Overnight_ZCTAs,
group = "Overnight (Midnight - 6 AM)",
fillColor = ~pal(Incidents),
fillOpacity = 0.7,
color = "black",
weight = 1,
label = ~paste0(
"ZIP Code: ",
ZCTA5CE20,
" | Overnight Assaults: ",
Incidents
),
popup = ~paste0(
"<strong>ZIP Code:</strong> ",
ZCTA5CE20,
"<br><strong>Overnight Assaults:</strong> ",
Incidents
),
highlightOptions = highlightOptions(
weight = 3,
color = "white",
fillOpacity = 0.9,
bringToFront = TRUE
)
) |>
##########################################################
# Add Layer Control
#
# The radio buttons allow viewers to display one
# category at a time.
##########################################################
addLayersControl(
baseGroups = c(
"Total Aggravated Assaults",
"Morning (6 AM - Noon)",
"Afternoon (Noon - 6 PM)",
"Evening (6 PM - Midnight)",
"Overnight (Midnight - 6 AM)"
),
options = layersControlOptions(
collapsed = FALSE
)
) |>
##########################################################
# Show Total Aggravated Assaults when map first loads.
##########################################################
hideGroup(
c(
"Morning (6 AM - Noon)",
"Afternoon (Noon - 6 PM)",
"Evening (6 PM - Midnight)",
"Overnight (Midnight - 6 AM)"
)
) |>
##########################################################
# Add Legend
##########################################################
addLegend(
position = "bottomright",
pal = pal,
values = Total_ZCTAs$Incidents,
title = "Aggravated Assaults"
)
############################################################
# Step 22: Display the Finished Map
############################################################
ChoroplethMap