Purpose of the Map

The original choropleth map displayed the total number of mapped aggravated assault records in each ZIP Code Tabulation Area (ZCTA) for June through August 2026. This showed how recorded assaults were distributed across areas during the summer, but it did not show how that distribution changed from month to month.

My modification adds an interactive month selector. Viewers can switch between June, July, and August to compare the geographic distribution of records. All three monthly views use the same geographic areas and color scale.

The map uses aggravated assault records from the Metro Nashville Police Department data service and 2020 ZCTA boundaries from the U.S. Census Bureau. ZCTAs are geographic areas used to summarize data; they should not be interpreted as neighborhood boundaries.

Interactive Map

Use the selector in the upper-right corner to choose June, July, or August. Darker shades represent larger monthly counts. Hover over an area for a quick label, or click it to see the ZCTA, month, and count. You can also zoom and pan.

The map includes 29 ZCTAs with at least one mapped record during the summer. After coordinate and date filtering, 0 records could not be assigned to a selected ZCTA and were excluded from the mapped counts.

Why the Modified Map Is Better

The modified map makes it possible to investigate changes over time. The original map combined three months into one total, so viewers could not determine whether records were concentrated in a particular month or distributed more evenly throughout the summer. The month selector lets viewers explore these differences directly.

The shared color scale is essential to this comparison. If each month had its own scale, the same shade of red could represent different counts in different months. This could make areas appear equally affected even when their counts differed. In the modified map, the same shade always represents the same count.

The geographic areas also remain consistent across the three views. If a displayed ZCTA has records in one month but none in another, it remains visible with a count of zero for the latter month. This prevents an area from disappearing simply because it has no mapped records in the selected month.

Together, these changes add a useful time comparison while preserving the original map’s familiar choropleth format.

How the Modification Works

Extracting the Month

The script converts the incident timestamp into a date and time using the America/Chicago time zone. It then uses format(Incident_Occurred, "%m") to extract the month number. The expression month.name[MonthNumber] converts that number into a month name, such as June.

This creates the Month column used to separate the records into monthly groups.

Counting Records by Area and Month

The original script counted records by ZCTA alone. The revised script uses:

count(ZCTA5CE20, Month, name = "Incidents")

Including both ZCTA5CE20 and Month produces a separate count for each combination of area and month. Each row of the resulting table represents one ZCTA in one month.

Keeping Areas Visible When the Count Is Zero

The complete() function creates all combinations of the summer-active ZCTAs and the three selected months. Its fill argument assigns zero to combinations with no records.

For example, if a ZCTA has mapped records in June but none in July, this step creates a July row with a count of zero. The same ZCTA can then appear in all three monthly layers.

This does not add areas with no mapped records during the entire summer. Those areas remain outside the displayed set, as in the original script.

Using a Shared Color Scale

The script creates the color palette once, before building the monthly layers. Its domain extends from zero to the largest monthly count across all ZCTAs and all three months.

Each layer uses this same palette. This keeps the relationship between color and count consistent when the viewer changes months.

Creating the Monthly Layers

A for loop runs once for each name in month_names: June, July, and August.

During each repetition, the script filters the count table to the selected month, joins those counts to the ZCTA boundaries, creates popup text, and adds the polygons to the map.

The argument group = selected_month gives each monthly layer its own name. These group names connect the polygon layers to the month selector.

Adding the Month Selector

The function addLayersControl() creates the interactive selector. Passing the month names to baseGroups makes them mutually exclusive choices, so viewers see one monthly layer at a time.

The script initially hides July and August and shows June. The street basemap remains visible because it is not assigned to one of the monthly groups.

Displaying the Map and Complete Code

The report runs the data preparation and mapping code in a chunk with include=FALSE. This creates the map without displaying package messages, intermediate tables, or the code at that point in the report.

A separate chunk displays ChoroplethMap as an interactive widget. The complete script appears at the end of the report using ref.label, which displays the existing code without downloading the data or building the map a second time.

Interpretation and Limitations

The map shows counts of mapped aggravated assault records, not population-adjusted crime rates. An area with a higher count should not automatically be interpreted as having a higher risk for an individual resident.

The script follows the original lesson’s approach of counting downloaded rows. It does not independently establish that every row represents a unique incident.

A zero means that no qualifying records were mapped to that displayed ZCTA for that month. It does not prove that no assaults occurred there. Records with missing or invalid coordinates are removed, and records without a match to a selected ZCTA are excluded.

Only ZCTAs with at least one mapped record during the summer appear. The map therefore does not provide complete coverage of areas with no records throughout the summer. It also displays full ZCTA polygons even though the crime data come from the Nashville data source.

Monthly counts are not adjusted for the number of days in each month: June has 30 days, while July and August have 31. The map supports descriptive comparisons, but it does not establish the causes of differences between areas or months.

Because the script downloads data when the document is knitted, counts may change if the source records are updated.

How I Used AI

I used OpenAI’s Codex assistant to review the original script and suggest three meaningful improvements: a monthly map selector, coverage of areas with zero mapped records throughout the summer, and a monthly breakdown within each popup.

The most useful suggestion was the monthly selector. I chose it because it adds a time comparison to the original map while keeping the project manageable. The recommendation to use one shared color scale was also useful because it makes the monthly views directly comparable.

I did not use the suggestion to add a monthly bar chart inside each popup. That feature could provide additional detail, but I chose to focus on comparing the geographic pattern across the whole map using the month selector.

AI helped draft the revised R script and the explanations in this report. I ran the revised R script in RStudio and confirmed that it worked. The explanations distinguish mapped record counts from crime rates and acknowledge the map’s coverage limitations.

Complete Modified Script

The following code includes the full data preparation and map-building script, followed by the command that displays the interactive map. It is shown for documentation and is not executed again in this section.

############################################################
# Nashville Aggravated Assaults: Monthly Choropleth Map
#
# Select June, July, or August 2026 using the map control.
# All three months use the same areas and color scale.
############################################################


############################################################
# Step 1: Install and Load Required Packages
############################################################

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

for (pkg in required_packages) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    install.packages(
      pkg,
      repos = "https://cloud.r-project.org"
    )
  }
}

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

options(
  tigris_use_cache = TRUE,
  timeout = 300
)


############################################################
# Step 2: Download Summer 2026 Aggravated Assault Records
#
# Retrieve matching IDs, then download small batches
# to avoid silently losing records to response-size limits.
############################################################

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'"
)

read_crime_api <- function(parameters) {

  response <- fromJSON(
    paste0(
      base_url,
      "?",
      parameters,
      "&f=json"
    )
  )

  if (!is.null(response$error)) {
    stop(
      "Crime API error: ",
      response$error$message
    )
  }

  response
}

id_response <- read_crime_api(
  paste0(
    "where=",
    URLencode(query, reserved = TRUE),
    "&returnIdsOnly=true"
  )
)

record_ids <- unique(id_response$objectIds)

if (length(record_ids) == 0) {
  stop(
    "No records were returned for June-August 2026. ",
    "Check the dates and source."
  )
}

id_batches <- split(
  record_ids,
  ceiling(seq_along(record_ids) / 100)
)

CrimeData <- map_dfr(
  id_batches,
  function(ids) {

    response <- read_crime_api(
      paste0(
        "objectIds=",
        paste(ids, collapse = ","),
        "&outFields=*",
        "&returnGeometry=false"
      )
    )

    if (isTRUE(response$exceededTransferLimit)) {
      stop(
        "The API truncated a batch. ",
        "Reduce the batch size and rerun."
      )
    }

    as_tibble(response$features$attributes)
  }
)

if (nrow(CrimeData) != length(record_ids)) {
  stop(
    "The number of downloaded records does not ",
    "match the requested IDs."
  )
}


############################################################
# Step 3: Prepare Dates, Coordinates, and Month Labels
############################################################

month_names <- c(
  "June",
  "July",
  "August"
)

CrimeData <- CrimeData |>
  mutate(
    Incident_Occurred = as.POSIXct(
      Incident_Occurred / 1000,
      origin = "1970-01-01",
      tz = "America/Chicago"
    ),

    MonthNumber = as.integer(
      format(Incident_Occurred, "%m")
    ),

    Month = month.name[MonthNumber]
  ) |>
  filter(
    !is.na(Longitude),
    !is.na(Latitude),
    between(Longitude, -180, 180),
    between(Latitude, -90, 90),
    format(Incident_Occurred, "%Y") == "2026",
    MonthNumber %in% 6:8
  )

if (nrow(CrimeData) == 0) {
  stop("No usable records remain after filtering.")
}

# As in the original lesson, each downloaded row is counted.
# These are record counts, not population-adjusted rates.


############################################################
# Step 4: Convert Records into a Spatial Layer
############################################################

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


############################################################
# Step 5: Download and Prepare ZCTA Boundaries
#
# The original "37" prefix filter is retained.
# It is not a complete selection of Tennessee ZCTAs.
############################################################

TN_ZCTAs <- zctas(
  cb = TRUE,
  year = 2020
) |>
  filter(
    startsWith(ZCTA5CE20, "37")
  ) |>
  st_transform(
    st_crs(CrimeData_sf)
  )


############################################################
# Step 6: Assign Each Record to a ZCTA
############################################################

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

if (nrow(CrimeWithZCTA) != nrow(CrimeData_sf)) {
  stop(
    "Some records matched multiple ZCTAs. ",
    "Inspect the spatial join."
  )
}

unmatched <- sum(
  is.na(CrimeWithZCTA$ZCTA5CE20)
)

message(
  "Records without a ZCTA match (excluded): ",
  unmatched
)


############################################################
# Step 7: Count Records by ZCTA AND Month
############################################################

MonthlyCounts <- CrimeWithZCTA |>
  st_drop_geometry() |>
  filter(
    !is.na(ZCTA5CE20)
  ) |>
  count(
    ZCTA5CE20,
    Month,
    name = "Incidents"
  )

if (nrow(MonthlyCounts) == 0) {
  stop("No records matched the selected ZCTAs.")
}


############################################################
# Step 8: Fill Missing Monthly Counts with Zero
############################################################

MonthlyCounts <- MonthlyCounts |>
  complete(
    ZCTA5CE20,
    Month = month_names,
    fill = list(Incidents = 0L)
  )


############################################################
# Step 9: Keep the Same ZCTAs for Every Monthly Layer
#
# Only ZCTAs with at least one mapped record during
# the entire summer are included.
############################################################

Nashville_ZCTAs <- TN_ZCTAs |>
  filter(
    ZCTA5CE20 %in% MonthlyCounts$ZCTA5CE20
  )


############################################################
# Step 10: Create One Shared Color Scale
############################################################

pal <- colorNumeric(
  palette = "Reds",
  domain = c(
    0,
    max(MonthlyCounts$Incidents)
  )
)


############################################################
# Step 11: Create the Base Map
############################################################

ChoroplethMap <- leaflet(
  Nashville_ZCTAs
) |>
  addProviderTiles(
    "Esri.WorldStreetMap"
  )


############################################################
# Step 12: Add One Polygon Layer for Each Month
############################################################

for (selected_month in month_names) {

  MonthMapData <- Nashville_ZCTAs |>
    left_join(
      MonthlyCounts |>
        filter(
          Month == selected_month
        ),
      by = "ZCTA5CE20"
    ) |>
    mutate(
      Popup = paste0(
        "<strong>ZCTA:</strong> ",
        ZCTA5CE20,

        "<br><strong>Month:</strong> ",
        Month,
        " 2026",

        "<br><strong>Mapped aggravated assault records:</strong> ",
        Incidents
      ),

      HoverLabel = paste0(
        ZCTA5CE20,
        " | ",
        Month,
        ": ",
        Incidents
      )
    )

  ChoroplethMap <- ChoroplethMap |>
    addPolygons(
      data = MonthMapData,

      group = selected_month,

      fillColor = ~pal(Incidents),
      fillOpacity = 0.7,

      color = "#555555",
      weight = 1,

      popup = ~Popup,
      label = ~HoverLabel,

      highlightOptions = highlightOptions(
        weight = 3,
        color = "#222222",
        bringToFront = TRUE
      )
    )
}


############################################################
# Step 13: Add the Month Selector and Shared Legend
############################################################

ChoroplethMap <- ChoroplethMap |>
  addLayersControl(
    baseGroups = month_names,

    position = "topright",

    options = layersControlOptions(
      collapsed = FALSE
    )
  ) |>
  hideGroup(
    c("July", "August")
  ) |>
  showGroup(
    "June"
  ) |>
  addLegend(
    position = "bottomright",

    pal = pal,

    values = c(
      0,
      max(MonthlyCounts$Incidents)
    ),

    title = "Mapped assault records<br>per month"
  ) |>
  addControl(
    html = paste0(
      "<strong>Nashville aggravated assaults</strong><br>",
      "June-August 2026: select a month at top right.<br>",
      "Same color scale for all months.<br>",
      "Only ZCTAs with mapped records during summer are shown.<br>",
      "0 = no mapped records that month; counts are not rates."
    ),

    position = "bottomleft"
  )
ChoroplethMap