# The CDC Socrata endpoint requested in the assignment. Preserve the API output separately.
api_url <- 'https://data.cdc.gov/resource/fpsi-y8tj.csv'
query <- list(`$where`=paste0("intent='FA_Deaths' AND period='",params$year,"'"),`$limit`='5000')
api_status <- 'Not attempted'; api_raw <- NULL; api_data <- NULL
if(isTRUE(params$use_live_api)) {
response <- tryCatch(httr::GET(api_url,query=query,httr::timeout(25)),error=function(e)e)
if(inherits(response,'error')) api_status <- paste('API unavailable:',conditionMessage(response))
else if(httr::status_code(response)!=200) api_status <- paste('API returned HTTP',httr::status_code(response), substr(httr::content(response,'text',encoding='UTF-8'),1,120))
else {
api_raw <- tryCatch(readr::read_csv(I(httr::content(response,'text',encoding='UTF-8')),show_col_types=FALSE),error=function(e)NULL)
if(is.null(api_raw)||nrow(api_raw)==0) api_status <- 'API returned no usable rows for the requested indicator and year'
else {
api_status <- paste('API responded:',nrow(api_raw),'rows;',paste(names(api_raw),collapse=', '))
write_csv(api_raw,'cdc_api_raw_extract.csv')
}
}
} else api_status <- 'Live API disabled by report parameter'
# Existing reference data is used ONLY as an explicitly labeled fallback. It is NOT the CDC API extract.
reference <- read_csv('reference_50_states.csv',show_col_types=FALSE)
stopifnot(nrow(reference)==50, n_distinct(reference$State)==50)
# Detect API columns conservatively; do not assert that its rate is age-adjusted.
if(!is.null(api_raw)) {
nm <- names(api_raw)
state_col <- intersect(c('state','state_name','geography','location'),nm)[1]
rate_col <- intersect(c('rate','death_rate','value'),nm)[1]
if(length(state_col)&&length(rate_col)&&!is.na(state_col)&&!is.na(rate_col)) {
api_data <- api_raw %>% transmute(State=as.character(.data[[state_col]]), API_Rate=suppressWarnings(as.numeric(.data[[rate_col]]))) %>% filter(State %in% reference$State, !is.na(API_Rate))
if(n_distinct(api_data$State)!=50 || anyDuplicated(api_data$State)) {api_status <- paste(api_status,'| Validation failed: not exactly 50 unique states'); api_data <- NULL}
} else {api_status <- paste(api_status,'| Rate or state field not recognized'); api_data <- NULL}
}
# A successful API response is not proof of measure comparability.
api_rate_verified <- FALSE
analysis_data <- reference %>% transmute(State,Law_Rank=Law_Rank_2026,Strictness=Strictness_1_to_5,
Strictness_Label=factor(str_to_title(Strictness_Label),levels=c('Very Lax','Lax','Moderate','Strict','Very Strict')),
Mortality_Rate=Firearm_Mortality_Rate_2024)
source_mode <- 'REFERENCE FALLBACK: 2024 age-adjusted mortality values from the supplied reference CSV; NOT verified as extracted from the requested API.'
if(!is.null(api_data) && api_rate_verified) {
analysis_data <- analysis_data %>% select(-Mortality_Rate) %>% left_join(api_data,by='State') %>% rename(Mortality_Rate=API_Rate)
source_mode <- 'CDC API EXTRACT: rate definition independently verified.'
}
analysis_data <- analysis_data %>% arrange(Strictness,desc(Mortality_Rate))
stopifnot(nrow(analysis_data)==50,!anyNA(analysis_data$Mortality_Rate))
summary <- analysis_data %>% group_by(Strictness,Strictness_Label) %>% summarise(States=n(),Mean=mean(Mortality_Rate),Median=median(Mortality_Rate),.groups='drop') %>% arrange(Strictness)
gap <- summary$Mean[1]-summary$Mean[5]
rho <- suppressWarnings(cor(analysis_data$Strictness,analysis_data$Mortality_Rate,method='spearman'))Data provenance and API status: REFERENCE FALLBACK: 2024 age-adjusted mortality values from the supplied reference CSV; NOT verified as extracted from the requested API.
API check: API responded: 51 rows; geoid, name, intent, period, count_sup, rate, data_as_of, ttm_date_range | Rate or state field not recognized
Important: The requested CDC dataset may report a different rate definition from the reference age-adjusted series. The report does not automatically substitute an unverified API rate. If the API is unavailable, the charts still render from the clearly labeled reference dataset.
Do states with stricter firearm-control laws experience lower firearm mortality?
In this fifty-state descriptive comparison, the ten states in the Very Lax category averaged 18.92 firearm deaths per 100,000 residents, versus 7.55 among the Very Strict states: a difference of 11.37 per 100,000. This is an association, not proof that laws caused the difference.
The policy rankings in the provided reference data are from 2026, whereas the mortality outcomes are from 2024. This timing mismatch prevents a same-year causal interpretation and must not be hidden.
Mortality measure. The reference dataset contains
2024 age-adjusted firearm deaths per 100,000 residents, covering firearm
suicides and homicides as well as other firearm-related deaths. The CDC
API request uses the dataset identifier fpsi-y8tj, filtered
to FA_Deaths and 2024; its rate definition is
not independently verified as equivalent to the
reference measure. If that API is not functional or not comparable, this
report discloses the fallback rather than pretending an API extraction
succeeded.
Law measure. States are ranked by the 2026 Everytown gun-law ranking in the provided reference CSV. We convert those ranks to an ordinal five-point Likert-type classification, where higher values indicate stronger laws:
scale_table <- tibble::tibble(`Law rank`=c('41–50','31–40','21–30','11–20','1–10'),`Score`=1:5,`Category`=c('Very Lax','Lax','Moderate','Strict','Very Strict'))
kable(scale_table,caption='Five equal-sized rank groups; a relative ranking, not a direct measure of legal strength.')| Law rank | Score | Category |
|---|---|---|
| 41–50 | 1 | Very Lax |
| 31–40 | 2 | Lax |
| 21–30 | 3 | Moderate |
| 11–20 | 4 | Strict |
| 1–10 | 5 | Very Strict |
The five bins represent relative position among states, not equal differences in the number or effectiveness of laws. Each category contains ten states. State averages are unweighted, so each state counts equally regardless of population.
state_order <- analysis_data %>% arrange(desc(Mortality_Rate)) %>% pull(State)
plot1 <- analysis_data %>% mutate(State=factor(State,levels=rev(state_order)),Column=rep(1:5,each=10)) %>%
mutate(Row=rep(1:10,5)) %>%
ggplot(aes(x=factor(Column),y=factor(Row),fill=Mortality_Rate))+
geom_tile(colour='white',linewidth=.8)+
geom_text(aes(label=paste0(State,'\n',round(Mortality_Rate,1)), colour=label_contrast(Mortality_Rate, 'inferno')),size=2.45,lineheight=.9,show.legend=FALSE)+
scale_colour_identity()+
scale_fill_viridis_c(option='inferno',name='Deaths / 100k',direction=1)+
scale_x_discrete(labels=NULL)+scale_y_discrete(labels=NULL)+
labs(title='Firearm mortality varies substantially across states',subtitle='50 states, ranked from higher to lower mortality; 2024 reference series',caption='Source: 2024 mortality reference file; see API provenance note above.')+
coord_equal()+theme(axis.title=element_blank(),axis.text=element_blank(),axis.ticks=element_blank(),panel.grid=element_blank())
print(plot1)Figure 1. State-by-state mortality heat map. A tile heat map makes every state legible and avoids giving visually larger states more prominence. Darker tiles identify higher mortality. Rates are per 100,000 residents.
Why this chart? The tile heat map allows direct comparison of all fifty states without misleading geographic area effects. Each cell includes a state label and exact rate, making high and low values identifiable even without relying on color alone. Message: firearm mortality is not uniform across states; the next question is whether law strictness aligns with these differences.
plot2 <- analysis_data %>% arrange(desc(Strictness),Law_Rank) %>% mutate(Column=rep(1:5,each=10),Row=rep(1:10,5)) %>%
ggplot(aes(factor(Column),factor(Row),fill=factor(Strictness)))+
geom_tile(colour='white',linewidth=.8)+
geom_text(aes(label=paste0(State,'\n',as.character(Strictness_Label)),colour=strictness_text[Strictness]),size=2.25,lineheight=.9,show.legend=FALSE)+
scale_colour_identity()+
scale_fill_manual(values=setNames(law_colors, as.character(1:5)),name='Strictness',labels=c('1 · Very Lax','2 · Lax','3 · Moderate','4 · Strict','5 · Very Strict'))+
scale_x_discrete(labels=NULL)+scale_y_discrete(labels=NULL)+
labs(title='A five-point scale translates law rankings into groups',subtitle='2026 ranking; ten states per category',caption='Source: Everytown 2026 rankings in reference CSV. Classification is based on rank quintiles.')+
coord_equal()+theme(axis.title=element_blank(),axis.text=element_blank(),axis.ticks=element_blank(),panel.grid=element_blank())
print(plot2)Figure 2. Gun-law strictness heat map. Discrete colors encode the five ordered categories, while printed category labels ensure the map is readable without color.
Why this chart? This companion heat map keeps the same tile-and-label design as Figure 1, providing visual continuity. Message: the strictness categories are relative groups; they do not imply that laws differ by identical amounts between adjacent scores.
plot3 <- summary %>% ggplot(aes(x=Strictness_Label,y='Mean firearm mortality',fill=Mean))+
geom_tile(colour='white',linewidth=2,height=.8)+
geom_text(aes(label=sprintf('%.2f\nper 100k',Mean),colour=label_contrast(Mean, 'plasma')),size=4.1,fontface='bold',show.legend=FALSE)+
scale_colour_identity()+
scale_fill_viridis_c(option='plasma',name='Mean rate')+
labs(title='Higher law strictness corresponds to lower mean mortality',subtitle='Unweighted averages across ten states per category',x='Gun-law strictness category',y=NULL,caption='2024 mortality outcomes grouped by 2026 law rankings; descriptive, not causal.')+
theme(panel.grid=element_blank(),axis.text.x=element_text(angle=15,hjust=1))
print(plot3)Figure 3. Category-level mortality heat map. Each cell displays the exact unweighted mean rate, emphasizing the difference between the laxest and strictest groups.
Why this chart? A single-row heat map puts the five categories side by side, with exact values printed inside the cells. Message: the largest contrast is between the Very Lax and Very Strict groups; readers should notice the gradient without interpreting it as a proven effect of legislation.
kable(summary %>% transmute(`Strictness score`=Strictness,Category=as.character(Strictness_Label),States,`Mean rate`=round(Mean,2),`Median rate`=round(Median,2)),caption='Descriptive statistics: 2024 firearm mortality per 100,000 by 2026 gun-law category.')| Strictness score | Category | States | Mean rate | Median rate |
|---|---|---|---|---|
| 1 | Very Lax | 10 | 18.92 | 18.35 |
| 2 | Lax | 10 | 17.13 | 16.85 |
| 3 | Moderate | 10 | 15.45 | 14.95 |
| 4 | Strict | 10 | 13.49 | 12.35 |
| 5 | Very Strict | 10 | 7.55 | 6.40 |
The Spearman correlation between the ordinal strictness score and the state mortality rate is -0.66. A negative value indicates that stricter categories tend to be associated with lower rates. This statistic does not control for confounding factors.
plot4 <- analysis_data %>% mutate(Strictness_Label=factor(Strictness_Label,levels=c('Very Lax','Lax','Moderate','Strict','Very Strict'))) %>%
group_by(Strictness_Label) %>% arrange(desc(Mortality_Rate),.by_group=TRUE) %>% mutate(Position=row_number()) %>% ungroup() %>%
ggplot(aes(x=Position,y=Strictness_Label,fill=Mortality_Rate))+
geom_tile(colour='white',linewidth=1)+geom_text(aes(label=paste0(str_replace(State,' ','\n'),'\n',round(Mortality_Rate,1)),colour=label_contrast(Mortality_Rate, 'magma')),size=2.4,lineheight=.88,show.legend=FALSE)+scale_colour_identity()+
scale_fill_viridis_c(option='magma',name='Deaths / 100k')+
scale_x_continuous(breaks=1:10,labels=1:10,expand=c(0,0))+
labs(title='Not every state follows the group average',subtitle='Each row is a law category; each tile is one state, ordered by its mortality rate within the group',x='State position within group',y=NULL,caption='Rates and categories are observational and use different reference years.')+
theme(panel.grid=element_blank(),axis.text.x=element_blank(),axis.ticks.x=element_blank())
print(plot4)Figure 4. Within-category state heat map. States are grouped by strictness and labeled with their mortality rates to show variation and exceptions that category averages conceal.
Why this chart? Group averages can hide substantial differences between states assigned the same law score. This heat map preserves the five-category comparison while showing each state’s observed rate. Message: gun-law rankings alone cannot explain all state-level firearm mortality variation.
Answer: In the supplied fifty-state reference data, states with stricter 2026 gun-law rankings generally have lower 2024 firearm mortality rates. The observed difference between the extreme groups is 11.37 deaths per 100,000, but this result does not establish that stricter laws caused fewer deaths.
There are four central limitations:
A stronger causal investigation would compare multiple years, use contemporaneous law measures, separate homicide from suicide, and adjust for confounders. The defensible conclusion for this Story is a descriptive association, not a causal claim.
reference_50_states.csv (2024 age-adjusted mortality
values, 2026 gun-law ranks).Reproducibility note: The report attempts the CDC
API request at render time, records whether it worked, and saves the
returned raw data as cdc_api_raw_extract.csv if successful.
It uses the reference data for charts unless the API’s state coverage
and rate definition have been verified as comparable.
An API error is reported prominently, not concealed.