The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
library(stringr)library(usmap)library(patchwork)
#load datamortality_data <-read.csv("mortality_data-table.csv") %>%clean_names()# Script to Scrape Historical Giffords Gun Law Scorecard Data (2020-2024)# This will extract state grades and rankings from multiple years# Define the URLs for each yearurls <-list("2020"="https://giffords.org/lawcenter/resources/scorecard2020/","2021"="https://giffords.org/lawcenter/resources/scorecard2021/","2022"="https://giffords.org/lawcenter/resources/scorecard2022/","2023"="https://giffords.org/lawcenter/resources/scorecard2023/","2024"="https://giffords.org/lawcenter/resources/scorecard/")# Function to scrape data from a single yearscrape_year <-function(url, year) {cat("Scraping", year, "data from:", url, "\n")tryCatch({# Read the webpage page <-read_html(url)# Try to find the table tables <- page %>%html_table(fill =TRUE)if (length(tables) >0) {cat(" Found", length(tables), "table(s)\n")# Look for the main data tablefor (i inseq_along(tables)) { table <- tables[[i]]# Check if this looks like the state data table# Should have columns for State, Grade, etc.if (ncol(table) >=3&&nrow(table) >=50) {cat(" Using table", i, "with", nrow(table), "rows and", ncol(table), "columns\n")# Clean up the table# Column names vary by year, so we need to be flexiblenames(table) <-make.names(names(table))# Add year column table$Year <- yearreturn(table) } } }# If table method didn't work, try alternative parsingcat(" Table method didn't work, trying alternative parsing...\n")# Look for state data in the HTML structure# Find all table rows rows <- page %>%html_nodes("tr")if (length(rows) >0) {cat(" Found", length(rows), "table rows\n")# Extract data from rows state_data <-lapply(rows, function(row) { cells <- row %>%html_nodes("td, th") %>%html_text(trim =TRUE)if (length(cells) >=3) {return(cells) }return(NULL) })# Remove NULL entries state_data <-Filter(Negate(is.null), state_data)if (length(state_data) >10) { # Should have at least 10 statescat(" Extracted data for", length(state_data), "rows\n")# Convert to data frame max_cols <-max(sapply(state_data, length)) state_data <-lapply(state_data, function(x) {length(x) <- max_colsreturn(x) }) df <-as.data.frame(do.call(rbind, state_data), stringsAsFactors =FALSE) df$Year <- yearreturn(df) } }cat(" WARNING: Could not extract data for", year, "\n")return(NULL) }, error =function(e) {cat(" ERROR scraping", year, ":", e$message, "\n")return(NULL) })}# Scrape all yearsall_data <-list()for (year innames(urls)) {cat("\n") result <-scrape_year(urls[[year]], year)if (!is.null(result)) { all_data[[year]] <- result }Sys.sleep(2)}
Scraping 2020 data from: https://giffords.org/lawcenter/resources/scorecard2020/
Found 1 table(s)
Using table 1 with 50 rows and 5 columns
Scraping 2021 data from: https://giffords.org/lawcenter/resources/scorecard2021/
Found 1 table(s)
Using table 1 with 51 rows and 5 columns
Scraping 2022 data from: https://giffords.org/lawcenter/resources/scorecard2022/
Found 1 table(s)
Using table 1 with 51 rows and 5 columns
Scraping 2023 data from: https://giffords.org/lawcenter/resources/scorecard2023/
Found 1 table(s)
Using table 1 with 51 rows and 5 columns
Scraping 2024 data from: https://giffords.org/lawcenter/resources/scorecard/
Found 1 table(s)
Using table 1 with 51 rows and 5 columns
# Save each year separatelycat("=== Saving Data ===\n")
# Try to combine all years if data structure is similarif (length(all_data) >1) {}
NULL
# Return the data for further useall_data
$`2020`
# A tibble: 50 × 6
Gun.LawStrength.Ranked. State X2020Grade Gun.DeathRate.Ranked.
<int> <chr> <chr> <int>
1 37 Alabama F 5
2 42 Alaska F 1
3 45 Arizona F 16
4 39 Arkansas F 9
5 1 California A 44
6 15 Colorado C+ 18
7 3 Connecticut A- 45
8 11 Delaware B 40
9 24 Florida C- 26
10 32 Georgia F 14
# ℹ 40 more rows
# ℹ 2 more variables: Gun.DeathRate.Per.100K. <dbl>, Year <chr>
$`2021`
# A tibble: 51 × 6
Gun.Law.Strength.............Ranked. State Grade Gun.Death.Rate......…¹
<chr> <chr> <chr> <chr>
1 31 Alabama F 5
2 41 Alaska F 6
3 42 Arizona F 20
4 50 Arkansas F 8
5 1 California A 44
6 13 Colorado B 22
7 3 Connecticut A- 45
8 12 Delaware B 25
9 24 Florida C- 29
10 28 Georgia F 15
# ℹ 41 more rows
# ℹ abbreviated name: ¹Gun.Death.Rate.............Ranked.
# ℹ 2 more variables: Gun.Death.Rate.............per.100K. <chr>, Year <chr>
$`2022`
# A tibble: 51 × 6
Gun.Law.Strength........................…¹ State Grade Gun.Death.Rate......…²
<chr> <chr> <chr> <chr>
1 38 Alab… F 4
2 41 Alas… F 6
3 42 Ariz… F 17
4 50 Arka… F 8
5 1 Cali… A 43
6 14 Colo… B 18
7 3 Conn… A- 45
8 13 Dela… B 23
9 23 Flor… C- 34
10 34 Geor… F 14
# ℹ 41 more rows
# ℹ abbreviated names:
# ¹Gun.Law.Strength..................................................Ranked.,
# ²Gun.Death.Rate..................................................Ranked.
# ℹ 2 more variables:
# Gun.Death.Rate..................................................per.100K. <chr>,
# Year <chr>
$`2023`
# A tibble: 51 × 6
Gun.Law.Strength........................…¹ State Grade Gun.Death.Rate......…²
<chr> <chr> <chr> <chr>
1 35 Alab… F 4
2 40 Alas… F 7
3 41 Ariz… F 12
4 48 Arka… F 8
5 1 Cali… A 44
6 10 Colo… A- 19
7 3 Conn… A 45
8 13 Dela… B+ 39
9 24 Flor… D+ 31
10 33 Geor… F 14
# ℹ 41 more rows
# ℹ abbreviated names:
# ¹Gun.Law.Strength..................................................Ranked.,
# ²Gun.Death.Rate..................................................Ranked.
# ℹ 2 more variables:
# Gun.Death.Rate..................................................per.100K. <chr>,
# Year <chr>
$`2024`
# A tibble: 51 × 6
Gun.Law.Strength........................…¹ State Grade Gun.Death.Rate......…²
<chr> <chr> <chr> <chr>
1 34 Alab… F 3
2 40 Alas… F 5
3 41 Ariz… F 14
4 48 Arka… F 7
5 1 Cali… A 44
6 10 Colo… A- 20
7 3 Conn… A 45
8 13 Dela… A- 39
9 24 Flor… C- 30
10 31 Geor… F 13
# ℹ 41 more rows
# ℹ abbreviated names:
# ¹Gun.Law.Strength..................................................Ranked.,
# ²Gun.Death.Rate..................................................Ranked.
# ℹ 2 more variables:
# Gun.Death.Rate..................................................per.100K. <chr>,
# Year <chr>
# Extract each year as its own data framedata_2020 <-as.data.frame(all_data[["2020"]])data_2021 <-as.data.frame(all_data[["2021"]])data_2022 <-as.data.frame(all_data[["2022"]])data_2023 <-as.data.frame(all_data[["2023"]])data_2024 <-as.data.frame(all_data[["2024"]])
# See what column names each year haslapply(all_data, names)
# Function to standardize column names AND data typesstandardize_columns <-function(df, year) {# Get current column names cols <-names(df)# Create new standardized names new_names <- cols# Standardize each columnfor (i inseq_along(cols)) { col <- cols[i]# Gun Law Rankif (grepl("Gun.*Law.*Strength.*Ranked", col, ignore.case =TRUE)) { new_names[i] <-"Gun_Law_Rank" }# State (keep as is)elseif (grepl("^State$", col)) { new_names[i] <-"State" }# Gradeelseif (grepl("Grade", col, ignore.case =TRUE)) { new_names[i] <-"Grade" }# Gun Death Rankelseif (grepl("Gun.*Death.*Rate.*Ranked", col, ignore.case =TRUE)) { new_names[i] <-"Gun_Death_Rank" }# Gun Death Rate (per 100K)elseif (grepl("Gun.*Death.*Rate.*100K", col, ignore.case =TRUE)) { new_names[i] <-"Gun_Death_Rate" }# Yearelseif (grepl("^Year$", col)) { new_names[i] <-"Year" } }# Apply new namesnames(df) <- new_names# Select columns and FIX DATA TYPES df <- df %>%select(State, Grade, Gun_Law_Rank, Gun_Death_Rank, Gun_Death_Rate, Year) %>%mutate(State =as.character(State),Grade =as.character(Grade),Gun_Law_Rank =as.numeric(as.character(Gun_Law_Rank)), # Convert to numericGun_Death_Rank =as.numeric(as.character(Gun_Death_Rank)), # Convert to numericGun_Death_Rate =as.numeric(as.character(Gun_Death_Rate)), # Convert to numericYear =as.numeric(as.character(Year)) # Convert to numeric ) %>%# Remove whitespacemutate(across(where(is.character), trimws)) %>%# Remove any header rows that snuck infilter(!is.na(Gun_Law_Rank) & State !="State"& State !="")return(df)}# Apply to all yearscleaned_data <-list()for (year innames(all_data)) {cat("Cleaning", year, "data...\n") cleaned_data[[year]] <-standardize_columns(all_data[[year]], year)}
Cleaning 2020 data...
Cleaning 2021 data...
Warning: There were 3 warnings in `mutate()`.
The first warning was:
ℹ In argument: `Gun_Law_Rank = as.numeric(as.character(Gun_Law_Rank))`.
Caused by warning:
! NAs introduced by coercion
ℹ Run `dplyr::last_dplyr_warnings()` to see the 2 remaining warnings.
Cleaning 2022 data...
Warning: There were 3 warnings in `mutate()`.
The first warning was:
ℹ In argument: `Gun_Law_Rank = as.numeric(as.character(Gun_Law_Rank))`.
Caused by warning:
! NAs introduced by coercion
ℹ Run `dplyr::last_dplyr_warnings()` to see the 2 remaining warnings.
Cleaning 2023 data...
Warning: There were 3 warnings in `mutate()`.
The first warning was:
ℹ In argument: `Gun_Law_Rank = as.numeric(as.character(Gun_Law_Rank))`.
Caused by warning:
! NAs introduced by coercion
ℹ Run `dplyr::last_dplyr_warnings()` to see the 2 remaining warnings.
Cleaning 2024 data...
Warning: There were 3 warnings in `mutate()`.
The first warning was:
ℹ In argument: `Gun_Law_Rank = as.numeric(as.character(Gun_Law_Rank))`.
Caused by warning:
! NAs introduced by coercion
ℹ Run `dplyr::last_dplyr_warnings()` to see the 2 remaining warnings.
# Now combine all yearscombined_data <-bind_rows(cleaned_data)combined_data <- combined_data %>%clean_names()
Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
ℹ Please use `linewidth` instead.
The mortality rate gap between states with very strict laws (~8-9 per 100k) and very lax laws (~20-22 per 100k) remains remarkably consistent across all four years.
# Filter for 2023plot_data <- complete_df %>%filter(year ==2023)# Heat map: Gun Law Strictness (1 = Very Lax → 5 = Very Strict)heat_gun_laws <-plot_usmap(data = plot_data, values ="likert_scale") +scale_fill_gradientn(colors =c("#08519c", "#3182bd", "#6baed6", "#bdd7e7", "#eff3ff"),name ="Gun Law Strictness",breaks =1:5,labels =c("1\n(Least strict)", "2", "3", "4", "5\n(Most strict)") ) +theme_void() +theme(legend.position ="bottom",legend.title =element_text(size =9, face ="bold"),legend.text =element_text(size =5) )# Heat map: Firearm Mortality Rateheat_mortality <-plot_usmap(data = plot_data, values ="rate") +scale_fill_gradientn(colors =c("#fff5f0", "#fcbba1", "#fc9272", "#fb6a4a", "#de2d26", "#a50f15"),name ="Mortality Rate (per 100k)",n.breaks =6 ) +theme_void() +theme(legend.position ="bottom",legend.title =element_text(size =9, face ="bold"),legend.text =element_text(size =5) )# Combine side by side with improved titlecombined_heatmaps <- heat_gun_laws + heat_mortality +plot_layout(ncol =2) +plot_annotation(title ="Stricter Gun Laws, Lower Mortality Rates",subtitle ="2023 Data",theme =theme(plot.title =element_text(size =15, hjust =0.5, face ="bold"),plot.subtitle =element_text(size =11, hjust =0.5, color ="gray40"),plot.caption =element_text(size =9, hjust =1, color ="gray50") ) )# Displaycombined_heatmaps
Warning: annotation$theme is not a valid theme.
Please use `theme()` to construct themes.
library(ggrepel)# Define the color palette oncemy_colors <-c("#08519c", "#3182bd", "#6baed6", "#bdd7e7", "#eff3ff")outliers <- plot_data %>%group_by(likert_scale) %>%mutate(Q1 =quantile(rate, 0.25),Q3 =quantile(rate, 0.75),IQR = Q3 - Q1,is_outlier = rate < (Q1 -1.5* IQR) | rate > (Q3 +1.5* IQR) ) %>%filter(is_outlier)ggplot(plot_data, aes(x =factor(likert_scale), y = rate)) +geom_boxplot(aes(fill =factor(likert_scale)), alpha =0.7) +geom_jitter(width =0.2, alpha =0.5, size =2, color ="gray40") +geom_text_repel(data = outliers,aes(label = state),size =3,max.overlaps =20 ) +scale_fill_manual(values = my_colors) +# CHANGED: Use same colorslabs(title ="Stricter Gun Laws Associated with Lower Firearm Mortality Rates",x ="Gun Law Strictness (1 = Least Strict, 5 = Most Strict)",y ="Mortality Rate (per 100k)" ) +theme_minimal() +theme(legend.position ="none")
Conclusion
This analysis examined the relationship between gun law strictness and firearm mortality rates across all 50 US states using data from the CDC and the Giffords Law Center spanning 2020-2023.
The visual evidence from the heat maps reveals a clear geographic pattern: states with the strictest gun laws (rated 4-5 on Likert scale) consistently show lower firearm mortality rates, often below 10 deaths per 100,000 people. In stark contrast, states with the least restrictive gun laws (rated 1-2), concentrated in the South and Mountain West, experience significantly higher mortality rates, frequently exceeding 20 deaths per 100,000 people.
The bar chart analysis reinforces this pattern. Among the 10 states with the highest mortality rates, 9 have gun law strictness ratings of 1 or 2 (Very Lax or Lax). Conversely, among the 10 states with the lowest mortality rates, the majority have strictness ratings of 4 or 5 (Strict or Very Strict). The boxplot distribution further illustrates this relationship, showing a consistent downward trend in median firearm mortality rates as gun law strictness increases from 1 to 5.
The trend analysis from 2020-2023 demonstrates that this relationship is not only strong but also persistent over time. The mortality rate gap between states with very strict laws (averaging 7-9 per 100,000) and very lax laws (averaging 19-22 per 100,000) remains remarkably consistent across all four years. Notably, states with the strictest gun laws also exhibit the least volatility in mortality rates, suggesting these policies may provide more stable public health outcomes. While all categories experienced an increase in 2021, likely reflecting broader societal impacts of the COVID-19 pandemic, the relative ordering remained unchanged.
Do stricter gun laws reduce firearm deaths? Based on this state-level analysis, the evidence strongly suggests yes. States with stricter gun control legislation demonstrate substantially lower firearm mortality rates compared to states with permissive gun laws, and this pattern holds consistently across multiple years.