̥ # Introduction Gun violence is a significant issue in the city of Philadelphia. There were 771 shooting incidents in the city in 2024 per the Philadelphia Police Department. The goal of this project is to utilize the data provided by the Philadelphia Police Department via OpenDataPhilly on shooting incidents to build a picture of the fatal shooting incidents occurring in the city. Who is most likely to be the victim of a fatal incident (age, race, sex)? What conditions of an incident make it more likely to be fatal (wound type)? When and where are the most fatal incidents occurring (time of day/year, location)?
Understanding the trends of fatal gun violence incidents can assist in painting a clearer picture of the problem and point towards potential solutions. Understanding the time and location of the most dangerous shootings provides guidance for police on where to focus their efforts. Understanding who is most at risk of a fatal shooting guides social and health services towards populations that may benefit from interventions to assist shooting victims.
The data is collected by the Philadelphia Police Department and provided via OpenDataPhilly. Below, we will load the data and gather the available variables for answering our questions.
Looking at the available fields we have a rich potential set of data to answer our questions. The age, race, sex and latino fields can provide a profile of the most frequent shooting victims. Wound can inform which types of injuries are most often fatal. Location, time and date can be used to determine when and where fatal incidents occur most often. These fields will be compared to the fatal column in order to determine fatality incidence.
# Install necessary packages (run this only once)
# install.packages(c("tidyverse", "sf", "leaflet", "ggplot2", "viridis", "readr", "sp", "leaflet.extras", "readxl", "htmltools", "htmlwidgets", "shiny", "dplyr"))
#install.packages("RColorBrewer")
# Load the packages
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.1.4 ✔ readr 2.1.5
## ✔ forcats 1.0.0 ✔ stringr 1.5.1
## ✔ ggplot2 3.5.2 ✔ tibble 3.2.1
## ✔ lubridate 1.9.4 ✔ tidyr 1.3.1
## ✔ purrr 1.0.4
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(sf)
## Linking to GEOS 3.13.0, GDAL 3.10.1, PROJ 9.5.1; sf_use_s2() is TRUE
library(leaflet)
library(ggplot2)
library(viridis)
## Loading required package: viridisLite
library(readr)
library(sp)
library(leaflet.extras)
library(htmltools)
library(htmlwidgets)
library(shiny)
library(dplyr)
library(RColorBrewer)
library(jsonlite)
##
## Attaching package: 'jsonlite'
##
## The following object is masked from 'package:shiny':
##
## validate
##
## The following object is masked from 'package:purrr':
##
## flatten
library(tidyverse)
library(readxl)
shootings.raw = read_delim(
"./philly_shootings.csv",
delim=",",
show_col_types = FALSE,
) %>% glimpse()
## Rows: 16,732
## Columns: 25
## $ the_geom <chr> "0101000020E61000001049CE6BA7C652C0F0FF5490EA0344…
## $ the_geom_webmercator <chr> "0101000020110F0000790F72E295E45FC1A02823583D9452…
## $ objectid <dbl> 634246, 634247, 634248, 634249, 634250, 634251, 6…
## $ year <dbl> 2015, 2015, 2015, 2015, 2015, 2015, 2015, 2015, 2…
## $ dc_key <dbl> 201502062409, 201503069342, 201512041905, 2015120…
## $ code <dbl> 3400, 400, 400, 400, 400, 400, 400, 400, 3400, 40…
## $ date_ <dttm> 2015-09-26, 2015-11-08, 2015-05-30, 2015-12-11, …
## $ time <time> 05:07:00, 12:50:00, 14:11:00, 18:00:00, 19:40:00…
## $ race <chr> "B", "A", "B", "B", "B", "B", "B", "B", "B", "B",…
## $ sex <chr> "M", "M", "M", "M", "F", "F", "F", "M", "F", "M",…
## $ age <dbl> 43, 36, 27, 23, 15, 21, 26, 33, 26, 43, 21, 35, 2…
## $ wound <chr> "Chest", "Arm", "Stomach", "Leg", "Leg", "Leg", "…
## $ officer_involved <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ offender_injured <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ offender_deceased <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ location <chr> "700 BLOCK Adams Ave", "1700 BLOCK S 5th St", "69…
## $ latino <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ point_x <dbl> -75.10397, -75.15405, -75.23670, -75.22840, -75.1…
## $ point_y <dbl> 40.03060, 39.92726, 39.91765, 39.91845, 40.04942,…
## $ dist <dbl> 2, 3, 12, 12, 14, 14, 14, 14, 14, 14, 14, 14, 14,…
## $ inside <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ outside <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
## $ fatal <dbl> 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ lat <dbl> 40.03060, 39.92726, 39.91765, 39.91845, 40.04942,…
## $ lng <dbl> -75.10397, -75.15405, -75.23670, -75.22840, -75.1…
First, we look at NAs to see where we may have missing data that needs to be cleaned up. Looking at the summary of NAs a group of 153 NAs shows up across multiple columns including fatal. We will investigate those to clean them up since they will prevent us from accurately summing the fatalities.
shootings.raw %>% summarise_all(~ sum(is.na(.)))
Based on this, it looks like certain columns, including fatal, are left blank in any incident that involves an officer. In these cases the only fatality data provided is offender deceased. However, this would leave out any other fatalities that occurred during the incident since not everyone injured in a shooting incident is necessarily an offender.
Critical data for our analysis is missing from all of these officer involved shootings - time, race, age, wound, latino and, most importantly, fatal. There are 40 fatal incidents with an officer involved for which there is incomplete data.
shootings.raw %>% filter(if_any(fatal, is.na))
shootings.raw %>% filter(officer_involved == 'Y')
shootings.officer = shootings.raw %>% filter(officer_involved == 'Y')
shootings.officer %>% filter(offender_deceased == 'Y')
We can make an adjusted table, adding a total_fatality column that includes offender_deceased. It will not be usable for the questions of the profile of the most frequent shooting victims and their wound types, or the time of day of incidents, since that data is missing, but it can be used for the geolocation and date questions.
This introduces some potential bias into our data because it is possible that we are excluding non-offender fatalities. It is unknown to us if those are excluded or just did not occur. However, it is more complete than not including these 40 fatalities at all.
This gives us a column, total_fatality, with no NAs that can be summed to a total of 3494 fatalities between 2015 and 2025.
shootings.adjusted = shootings.raw
shootings.adjusted$offender_fatal <- ifelse(shootings.raw$offender_deceased == "Y", 1, 0)
shootings.adjusted <- shootings.adjusted %>%
mutate(fatal = replace_na(fatal, 0))
shootings.adjusted$total_fatality <- shootings.adjusted$offender_fatal + shootings.adjusted$fatal
shootings.adjusted
sum(shootings.adjusted$total_fatality)
## [1] 3496
Looking at fatality totals by year we can see that partial data is included for 2025. This will obviously be skewed on an annual basis since 2025 is not complete yet. We can see here that fatal shooting incidents rose from 2015 to 2021, peaked in 2021, then fell from 2021-2024.
fatal.byyear = aggregate(shootings.adjusted['total_fatality'], by=shootings.adjusted['year'], sum)
fatal.15to24 <- fatal.byyear %>%
filter(year != 2025)
ggplot(data = fatal.15to24, aes(x = year, y = total_fatality)) +
ggtitle("Total Fatal Shooting Victims by Year 2015-2024") +
geom_col() +
scale_x_continuous(breaks = 2015:2024)
## Fatal Shooting Victim Profile
For this analysis we will be only looking at victims of criminal shootings, not officer involved shootings so we will only use the fatal column, not total_fatality. ṁ ### Age of Victims First let’s get the mean age of fatal shooting victim.
fatal.only <- shootings.adjusted %>%
filter(fatal == 1)
fatal.only <- fatal.only[!is.na(fatal.only$age), ]
mean(fatal.only$age)
## [1] 30.72565
Now, we will look at the overall distribution of shooting victim age.
ggplot(data = shootings.adjusted, aes(x = age, y = fatal)) +
ggtitle("Age Distribution of Fatal Shooting Victims") +
geom_col()
## Warning: Removed 274 rows containing missing values or values outside the scale range
## (`geom_col()`).
## Counting shootings to which body parts were most fatal
fatal_df <- shootings.raw[shootings.raw$fatal == 1, ]
injury_counts <- table(fatal_df$wound)
injury_counts
##
## abdomen Abdomen Arm back Back
## 1 86 13 2 116
## buttocks Buttocks chest Chest Foot
## 1 7 5 463 2
## Groin Hand head Head HEAD
## 3 2 15 755 3
## Hip Leg multi MULTI Multiple
## 5 40 1 5 1474
## Multiple/Head Multiple/HEAD MULTIPLE/HEAD Neck Shoulder
## 314 1 1 53 22
## Stomach TORSO Unknown
## 53 1 11
sorted_injury_counts <- sort(injury_counts,decreasing = TRUE)
print(sorted_injury_counts)
##
## Multiple Head Chest Multiple/Head Back
## 1474 755 463 314 116
## Abdomen Neck Stomach Leg Shoulder
## 86 53 53 40 22
## head Arm Unknown Buttocks chest
## 15 13 11 7 5
## Hip MULTI Groin HEAD back
## 5 5 3 3 2
## Foot Hand abdomen buttocks multi
## 2 2 1 1 1
## Multiple/HEAD MULTIPLE/HEAD TORSO
## 1 1 1
barplot(sorted_injury_counts,las = 2,
col = "steelblue", # Bar color
main = "Injury Types in Fatal Shootings",
ylab = "Number of Fatal Cases",
cex.names = 0.7)
fatal_df <- shootings.raw[shootings.raw$fatal==1,]
head(fatal_df)
fatal_df$age_group <- cut(fatal_df$age,
breaks = seq(0, 100, by = 10),
right = FALSE,
labels = c("0-9", "10-19", "20-29", "30-39",
"40-49", "50-59", "60-69", "70-79",
"80-89", "90-99"))
age_counts <- table(fatal_df$age_group)
age_counts <- sort(age_counts, decreasing = TRUE)
print(age_counts)
##
## 20-29 30-39 10-19 40-49 50-59 60-69 0-9 70-79 80-89 90-99
## 1413 907 425 410 180 52 14 12 5 1
barplot(age_counts,
main = "Fatal Shootings by Age Group",
xlab = "Age Group",
ylab = "Number of Fatal Shootings",
col = "darkred",
cex.names = 0.7)
First, we will calculate the mean age of fatal shooting victim by year.
age_avg <- fatal.only %>%
group_by(year) %>%
summarize(average_value = mean(age))
age_avg
First, we will calculate the mean age of fatal shooting victim by year.
age_avg <- fatal.only %>%
group_by(year) %>%
summarize(average_value = mean(age))
age_avg
Then, let’s visualize the average age by year for 2015 - 2014. We are excluding 2025 because it is a partial year of data.
ggplot(data = age_avg, aes(x = year, y = average_value)) +
ggtitle("Average Age of Fatal Shooting Victims by Year 2015-2024") +
geom_col() +
scale_x_continuous(breaks = 2015:2024)
# View structure
glimpse(shootings.adjusted)
## Rows: 16,732
## Columns: 27
## $ the_geom <chr> "0101000020E61000001049CE6BA7C652C0F0FF5490EA0344…
## $ the_geom_webmercator <chr> "0101000020110F0000790F72E295E45FC1A02823583D9452…
## $ objectid <dbl> 634246, 634247, 634248, 634249, 634250, 634251, 6…
## $ year <dbl> 2015, 2015, 2015, 2015, 2015, 2015, 2015, 2015, 2…
## $ dc_key <dbl> 201502062409, 201503069342, 201512041905, 2015120…
## $ code <dbl> 3400, 400, 400, 400, 400, 400, 400, 400, 3400, 40…
## $ date_ <dttm> 2015-09-26, 2015-11-08, 2015-05-30, 2015-12-11, …
## $ time <time> 05:07:00, 12:50:00, 14:11:00, 18:00:00, 19:40:00…
## $ race <chr> "B", "A", "B", "B", "B", "B", "B", "B", "B", "B",…
## $ sex <chr> "M", "M", "M", "M", "F", "F", "F", "M", "F", "M",…
## $ age <dbl> 43, 36, 27, 23, 15, 21, 26, 33, 26, 43, 21, 35, 2…
## $ wound <chr> "Chest", "Arm", "Stomach", "Leg", "Leg", "Leg", "…
## $ officer_involved <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ offender_injured <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ offender_deceased <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ location <chr> "700 BLOCK Adams Ave", "1700 BLOCK S 5th St", "69…
## $ latino <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ point_x <dbl> -75.10397, -75.15405, -75.23670, -75.22840, -75.1…
## $ point_y <dbl> 40.03060, 39.92726, 39.91765, 39.91845, 40.04942,…
## $ dist <dbl> 2, 3, 12, 12, 14, 14, 14, 14, 14, 14, 14, 14, 14,…
## $ inside <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ outside <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
## $ fatal <dbl> 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ lat <dbl> 40.03060, 39.92726, 39.91765, 39.91845, 40.04942,…
## $ lng <dbl> -75.10397, -75.15405, -75.23670, -75.22840, -75.1…
## $ offender_fatal <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ total_fatality <dbl> 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
summary(shootings.adjusted)
## the_geom the_geom_webmercator objectid year
## Length:16732 Length:16732 Min. :634246 Min. :2015
## Class :character Class :character 1st Qu.:638429 1st Qu.:2018
## Mode :character Mode :character Median :642612 Median :2020
## Mean :642612 Mean :2020
## 3rd Qu.:646794 3rd Qu.:2022
## Max. :650977 Max. :2025
##
## dc_key code date_
## Min. :1.502e+03 Min. : 100.0 Min. :2015-01-01 00:00:00.00
## 1st Qu.:2.018e+11 1st Qu.: 300.0 1st Qu.:2018-04-02 00:00:00.00
## Median :2.020e+11 Median : 400.0 Median :2020-09-25 00:00:00.00
## Mean :2.001e+11 Mean : 428.5 Mean :2020-05-14 23:53:22.39
## 3rd Qu.:2.022e+11 3rd Qu.: 400.0 3rd Qu.:2022-06-27 06:00:00.00
## Max. :2.025e+11 Max. :3700.0 Max. :2025-04-28 00:00:00.00
## NA's :167
## time race sex age
## Length:16732 Length:16732 Length:16732 Min. : 0.00
## Class1:hms Class :character Class :character 1st Qu.: 21.00
## Class2:difftime Mode :character Mode :character Median : 27.00
## Mode :numeric Mean : 29.34
## 3rd Qu.: 35.00
## Max. :117.00
## NA's :274
## wound officer_involved offender_injured offender_deceased
## Length:16732 Length:16732 Length:16732 Length:16732
## Class :character Class :character Class :character Class :character
## Mode :character Mode :character Mode :character Mode :character
##
##
##
##
## location latino point_x point_y
## Length:16732 Min. :0.000 Min. :-75.27 Min. :39.88
## Class :character 1st Qu.:0.000 1st Qu.:-75.19 1st Qu.:39.97
## Mode :character Median :0.000 Median :-75.16 Median :39.99
## Mean :0.123 Mean :-75.16 Mean :39.99
## 3rd Qu.:0.000 3rd Qu.:-75.13 3rd Qu.:40.02
## Max. :1.000 Max. :-74.96 Max. :40.13
## NA's :153 NA's :33 NA's :33
## dist inside outside fatal
## Min. : 1.00 Min. :0.00000 Min. :0.0000 Min. :0.0000
## 1st Qu.:15.00 1st Qu.:0.00000 1st Qu.:1.0000 1st Qu.:0.0000
## Median :22.00 Median :0.00000 Median :1.0000 Median :0.0000
## Mean :20.91 Mean :0.06333 Mean :0.9367 Mean :0.2066
## 3rd Qu.:25.00 3rd Qu.:0.00000 3rd Qu.:1.0000 3rd Qu.:0.0000
## Max. :77.00 Max. :1.00000 Max. :1.0000 Max. :1.0000
## NA's :2 NA's :153 NA's :153
## lat lng offender_fatal total_fatality
## Min. :39.88 Min. :-75.27 Min. :0.000000 Min. :0.0000
## 1st Qu.:39.97 1st Qu.:-75.19 1st Qu.:0.000000 1st Qu.:0.0000
## Median :39.99 Median :-75.16 Median :0.000000 Median :0.0000
## Mean :39.99 Mean :-75.16 Mean :0.002391 Mean :0.2089
## 3rd Qu.:40.02 3rd Qu.:-75.13 3rd Qu.:0.000000 3rd Qu.:0.0000
## Max. :40.13 Max. :-74.96 Max. :1.000000 Max. :1.0000
## NA's :28 NA's :28
shootings.spatial <- shootings.adjusted %>%
filter(!is.na(lat) & !is.na(lng)) %>%
rename(latitude = lat, longitude = lng)
shootings_sf <- st_as_sf(shootings.spatial , coords = c("longitude", "latitude"), crs = 4326)
Visualizes Philadelphia shooting incidents using geospatial coordinates. The scatter plot reveals the geographic distribution, with red dots highlighting significant clustering of events in specific areas of the city.
ggplot(data = shootings_sf) +
geom_sf(alpha = 0.3, color = "red") +
theme_minimal() +
labs(title = "Philadelphia Shooting Incidents",
subtitle = "Based on geospatial coordinates",
caption = "Source: philly_shootings.csv")
This interactive map displays shooting incidents in Philadelphia from 2015 to 2025. Each dot represents a single event and is color-coded by year. With all years selected, the visualization reveals the cumulative geographic distribution and extremely dense concentration of shootings in specific neighborhoods across the city. Futher incidents can be filtered based on year. Clicking a data point reveals a pop-up with specific details for that shooting, including its exact date, location, and fatality/injury information.
if (!"total_fatality" %in% names(shootings_sf) || !"year" %in% names(shootings_sf) || !"date_" %in% names(shootings_sf) || !"location" %in% names(shootings_sf) ) {
stop("shootings_sf is missing one or more required columns: year, date_, location, total_fatality.")
}
if (!"unique_id" %in% names(shootings_sf)) {
shootings_sf <- shootings_sf %>%
dplyr::mutate(unique_id = paste0("marker-", dplyr::row_number(), "-", year))
}
# Create unique list of years
years <- if ("year" %in% names(shootings_sf)) sort(unique(shootings_sf$year)) else character(0)
# Create color palette
if (length(years) > 0) {
if (length(years) <= 8) {
year_palette_colors <- RColorBrewer::brewer.pal(max(3, length(years)), "Dark2")
if (length(years) < 3) {
year_palette_colors <- year_palette_colors[1:length(years)]
}
} else {
year_palette_colors <- grDevices::colorRampPalette(RColorBrewer::brewer.pal(min(length(years), 9), "Dark2"))(length(years))
}
year_colors <- leaflet::colorFactor(palette = year_palette_colors, domain = years)
} else {
year_colors <- NULL
warning("Year column not found or no unique years in shootings_sf. Map may not display year layers correctly.")
}
# Create base map centered on Philadelphia
center_lon <- -75.1652
center_lat <- 39.9526
m <- leaflet::leaflet(shootings_sf) %>%
leaflet::setView(lng = center_lon, lat = center_lat, zoom = 11) %>%
leaflet::addProviderTiles(leaflet::providers$CartoDB.Positron) %>%
htmlwidgets::onRender("function(el, x) { el.classList.add('safegraph-map'); }")
# Add circle markers by year
if (length(years) > 0 && !is.null(year_colors)) {
for (yr in years) {
year_data <- shootings_sf %>% dplyr::filter(year == yr)
if (nrow(year_data) > 0) {
m <- m %>% leaflet::addCircleMarkers(
data = year_data,
radius = 5,
color = ~year_colors(year),
fillOpacity = 0.7,
stroke = FALSE,
group = as.character(yr),
popup = ~paste(
"<b>Year:</b>", htmltools::htmlEscape(year), "<br>",
"<b>Date:</b>", htmltools::htmlEscape(date_), "<br>",
"<b>Location:</b>", htmltools::htmlEscape(location), "<br>",
"<b>Fatality:</b>", ifelse(total_fatality > 0, "Yes", "No"),
"<b>Wound:</b>", htmltools::htmlEscape(wound)
),
layerId = ~unique_id
)
}
}
}
# Add layer control
if (length(years) > 0) {
m <- m %>% leaflet::addLayersControl(
overlayGroups = as.character(years),
options = leaflet::layersControlOptions(collapsed = FALSE)
)
}
## For button
year_groups_json_str <- jsonlite::toJSON(as.character(years))
select_toggle_js <- paste0(
"function(el, x, data) {\n",
" setTimeout(function() {\n",
" var yearGroups = ", year_groups_json_str, ";\n",
" if (!yearGroups || yearGroups.length === 0) { console.warn('No year groups for toggle button.'); return; }\n",
" var layersControlContainer = el.querySelector('.leaflet-control-layers-overlays');\n",
" if (!layersControlContainer) { console.warn('Layers control container not found for toggle button.'); return; }\n",
"\n",
" var toggleButton = L.DomUtil.create('button', 'leaflet-control-layers-selector safegraph-button');\n",
" toggleButton.style.marginBottom = '10px';\n",
"\n",
" function getCurrentAllCheckedState() {\n",
" if (yearGroups.length === 0) return false;\n",
" for (var i = 0; i < yearGroups.length; i++) {\n",
" var groupStr = String(yearGroups[i]);\n",
" var checkbox = layersControlContainer.querySelector('input[type=\"checkbox\"][value=\"' + groupStr + '\"]');\n",
" if (!checkbox || !checkbox.checked) return false;\n",
" }\n",
" return true;\n",
" }\n",
"\n",
" function updateButtonText() {\n",
" if (getCurrentAllCheckedState()) {\n",
" toggleButton.innerHTML = 'Deselect All Years';\n",
" } else {\n",
" toggleButton.innerHTML = 'Select All Years';\n",
" }\n",
" }\n",
"\n",
" toggleButton.onclick = function() {\n",
" var currentlyAllChecked = getCurrentAllCheckedState();\n",
" var targetState = !currentlyAllChecked;\n",
" yearGroups.forEach(function(group) {\n",
" var groupStr = String(group);\n",
" var checkbox = layersControlContainer.querySelector('input[type=\"checkbox\"][value=\"' + groupStr + '\"]');\n",
" if (checkbox) {\n",
" if (checkbox.checked !== targetState) {\n",
" checkbox.click();\n",
" }\n",
" }\n",
" });\n",
" updateButtonText();\n",
" };\n",
"\n",
" layersControlContainer.parentNode.insertBefore(toggleButton, layersControlContainer);\n",
" updateButtonText(); /* Set initial button text */\n",
" }, 250);\n",
"}\n"
)
m <- m %>% htmlwidgets::onRender(select_toggle_js)
# Add legend
if (length(years) > 0 && !is.null(year_colors)) {
m <- m %>% leaflet::addLegend(
position = "bottomleft",
pal = year_colors,
values = shootings_sf$year,
title = "Shooting Year",
opacity = 1
)
}
# CSS Styling using paste0
css_styling_js <- paste0(
"function(el, x) {\n",
" var css = `\n",
" .safegraph-map { border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); overflow: hidden; }\n",
" .safegraph-button { background-color: #f0f0f0; border: 1px solid #ccc; border-radius: 4px; padding: 4px 8px; font-size: 12px; cursor: pointer; transition: background-color 0.3s ease; font-family: 'Arial', sans-serif; color: #333; display: block; width: 100%; text-align: center; white-space: nowrap; box-sizing: border-box; }\n",
" .safegraph-button:hover { background-color: #e0e0e0; }\n",
" .leaflet-control-layers-list input[type='checkbox'] { margin-right: 4px; }\n",
" .leaflet-popup-content { font-size: 12px; line-height: 1.4; }\n",
" .leaflet-popup-content b { font-weight: 600; color: #2c3e50; }\n",
" .leaflet-control-layers { background-color: rgba(255,255,255,0.9); border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); padding: 6px 10px; font-size: 12px; color: #34495e; }\n",
" .leaflet-control-layers-title { font-weight: bold; margin-bottom: 6px; color: #2c3e50; }\n",
" `;\n",
" var style = document.createElement('style');\n",
" style.type = 'text/css';\n",
" style.appendChild(document.createTextNode(css));\n",
" document.head.appendChild(style);\n",
" if (!el.classList.contains('safegraph-map')) { el.classList.add('safegraph-map'); }\n",
"}\n"
)
m <- m %>% htmlwidgets::onRender(css_styling_js)
# Display map
m
Over the last decade, 16,704 total incidents have occurred across the city. To better visualize these widespread events, the data was organized into geographical clusters and displayed as a heatmaps for various types of incidents, revealing areas with the highest concentrations.
center_lon <- -75.1652
center_lat <- 39.9526
# Prepare base data for the map
shootings_sf_for_map <- shootings_sf %>%
dplyr::mutate(
incident_color = ifelse(total_fatality > 0, "red", "orange"),
fatality_label = ifelse(total_fatality > 0, "Fatal", "Non-Fatal")
)
# Create filtered datasets for new heatmap layers
officer_involved_fatal_sf <- shootings_sf_for_map %>%
dplyr::filter(officer_involved == 'Y' & total_fatality > 0)
officer_involved_non_fatal_sf <- shootings_sf_for_map %>%
dplyr::filter(officer_involved == 'Y' & total_fatality == 0)
offender_deceased_sf <- shootings_sf_for_map %>%
dplyr::filter(offender_deceased == 'Y')
all_officer_involved_sf <- shootings_sf_for_map %>%
dplyr::filter(officer_involved == 'Y')
# Create the Leaflet map
map_enhanced_heatmaps <- leaflet::leaflet(data = shootings_sf_for_map) %>% # Base data
leaflet::setView(lng = center_lon, lat = center_lat, zoom = 11) %>%
leaflet::addProviderTiles(leaflet::providers$CartoDB.Positron, group = "Base Map") %>%
# Clustered Markers (as before)
leaflet::addAwesomeMarkers(
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
icon = ~leaflet::awesomeIcons(
icon = 'info-circle',
library = 'glyphicon',
markerColor = incident_color,
iconColor = 'white'
),
popup = ~paste(
"<b>Year:</b>", htmltools::htmlEscape(year), "<br>",
"<b>Date:</b>", htmltools::htmlEscape(date_), "<br>",
"<b>Location:</b>", htmltools::htmlEscape(location), "<br>",
"<b>Fatality:</b>", ifelse(total_fatality > 0, "Yes", "No"), "<br>",
"<b>Wound:</b>", if ("wound" %in% names(.)) htmltools::htmlEscape(wound) else "N/A"
),
clusterOptions = leaflet::markerClusterOptions(),
group = "Shooting Incidents (Clusters)"
) %>%
# Heatmap
leaflet.extras::addHeatmap(
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (All Incidents)"
) %>%
# Heatmap for All Fatal Incidents
leaflet.extras::addHeatmap(
data = dplyr::filter(shootings_sf_for_map, total_fatality > 0),
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (All Fatal Incidents)"
) %>%
# Heatmap for Officer Involved & Fatal Incidents
leaflet.extras::addHeatmap(
data = officer_involved_fatal_sf,
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (Officer Involved & Fatal)"
) %>%
# Heatmap for Officer Involved & Non-Fatal Incidents
leaflet.extras::addHeatmap(
data = officer_involved_non_fatal_sf,
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (Officer Involved & Non-Fatal)"
) %>%
# Heatmap for Offender Deceased Incidents
leaflet.extras::addHeatmap(
data = offender_deceased_sf,
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (Offender Deceased)"
) %>%
# Heatmap for All Officer Involved Incidents
leaflet.extras::addHeatmap(
data = all_officer_involved_sf,
lng = ~sf::st_coordinates(geometry)[,1],
lat = ~sf::st_coordinates(geometry)[,2],
blur = 20, max = 0.05, radius = 15,
group = "Heatmap (All Officer Involved)"
) %>%
# Legend for Clustered Markers
leaflet::addLegend(
position = "bottomright",
colors = c("red", "orange"),
labels = c("Fatal Incident", "Non-Fatal Incident"),
title = "Incident Type (Markers)",
opacity = 1,
group = "Shooting Incidents (Clusters)"
) %>%
# Layers Control to switch between layers
leaflet::addLayersControl(
baseGroups = "Base Map",
overlayGroups = c(
"Shooting Incidents (Clusters)",
"Heatmap (All Incidents)",
"Heatmap (All Fatal Incidents)",
"Heatmap (All Officer Involved)",
"Heatmap (Officer Involved & Fatal)",
"Heatmap (Officer Involved & Non-Fatal)",
"Heatmap (Offender Deceased)"
),
options = leaflet::layersControlOptions(collapsed = FALSE)
) %>%
# Hide all heatmap layers by default so clusters are visible first
leaflet::hideGroup(c(
"Heatmap (All Incidents)",
"Heatmap (All Fatal Incidents)",
"Heatmap (All Officer Involved)",
"Heatmap (Officer Involved & Fatal)",
"Heatmap (Officer Involved & Non-Fatal)",
"Heatmap (Offender Deceased)"
))
# Display the map
map_enhanced_heatmaps