Overview

Row

What’s Killing Kenyans?

This dashboard visualises mortality patterns in Kenya using the KNBS Vital Statistics Report 2024, examining how cause of death varies by sex, age, and county. It highlights three things: men die at higher rates than women across nearly every age group, the causes that kill differ sharply by life stage, and fewer than half of Kenya’s expected deaths are ever formally registered — with that gap falling unevenly across counties and by sex.

Row

206,415
Registered deaths, 2024
126
Male deaths per 100 female
44.8%
Share of deaths actually registered

Row

The undercount, in one bar

How many of the deaths Kenya expected in 2024 were actually registered, versus how many were never recorded at all.

National completeness trend, 2020-2024

The share of Kenya’s expected deaths that were actually registered, tracked year by year.

Row

Note: figures on this page describe registered deaths in Kenya’s civil registration system. As the charts above show, that’s fewer than half of all deaths estimated to have occurred — treat these as a subset the system captured, not a full national total.

Age & Sex

Row

Where the gap between men and women widens: sex ratio at death by age

For every 100 women who died in a given age group, how many men died. 100 means equal numbers of male and female deaths; above 100 means more men died.

Row

Same causes, different weight: leading causes of death by sex, 2024

The top causes of registered death in 2024, comparing how many men and how many women died of each.

Causes by Age

Row

What kills you depends on how old you are: leading causes by age band, 2024

The five leading causes of registered death within each age group, from newborns through age 60+. Bar length is number of deaths.

Geography

Row

Where registration is most and least complete

Which counties registered the largest and smallest share of their expected deaths in 2024.

Row

Full county table

Every county’s registered deaths, expected deaths, and completeness rate. Sortable and searchable.
---
title: "What's Killing Kenyans? Mortality by Sex, Age, and County"
output:
  flexdashboard::flex_dashboard:
    orientation: rows
    vertical_layout: fill
    theme: flatly
    source_code: embed
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(echo = FALSE, warning = FALSE, message = FALSE)

library(flexdashboard)
library(dplyr)
library(tidyr)
library(ggplot2)
library(plotly)
library(leaflet)
library(sf)
library(scales)
library(DT)

# raster/terra (pulled in by sf/leaflet) are known to silently mask
# common dplyr verbs -- pin these explicitly so the right version
# always wins, regardless of package load order.
filter <- dplyr::filter
select <- dplyr::select
recode <- dplyr::recode
rename <- dplyr::rename

source("data_prep.R")   # loads and tidies all data frames used below

# House style for the static ggplot layer before it's handed to ggplotly()
theme_story <- function() {
  theme_minimal(base_size = 12) +
    theme(
      panel.grid.minor = element_blank(),
      plot.title = element_text(face = "bold", size = 13),
      plot.subtitle = element_text(color = "grey40", size = 10)
    )
}

pal_sex <- c("Male" = "#2b6cb0", "Female" = "#c05621")
```

<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600&display=swap');

:root {
  --ink: #1a1a1a;
  --ink-soft: #4a4a4a;
  --bg-warm: #faf9f7;
  --card-bg: #ffffff;
  --gold: #b7862c;
  --hairline: #e8e5df;
}

body, .navbar, .chart-wrapper, .DTFC_LeftBodyWrapper, table.dataTable {
  font-family: 'Inter', -apple-system, sans-serif;
  color: var(--ink);
}

.section.level1, .storyboard, .container-fluid.dashboard-page {
  background-color: var(--bg-warm);
}

/* --- Navbar: pull out of flexdashboard's default teal/blue and into
   the near-black, minimal register the rest of your site uses --- */
.navbar-inverse {
  background-color: #141414 !important;
  border: none !important;
  box-shadow: 0 1px 0 rgba(255,255,255,0.06);
}
.navbar-inverse .navbar-brand {
  font-family: 'Playfair Display', serif;
  font-weight: 700;
}
.navbar-inverse .navbar-nav > li > a {
  color: #b8b8b8 !important;
  font-size: 13px;
  letter-spacing: 0.4px;
  text-transform: uppercase;
}
.navbar-inverse .navbar-nav > .active > a,
.navbar-inverse .navbar-nav > .active > a:hover {
  background-color: transparent !important;
  color: var(--gold) !important;
  border-bottom: 2px solid var(--gold);
}

/* --- Hero: dark, editorial, serif headline, no rainbow gradient --- */
.hero-banner {
  background: var(--ink);
  color: white;
  padding: 44px 40px;
  border-radius: 4px;
  margin-bottom: 6px;
}
.hero-banner h1 {
  font-family: 'Playfair Display', serif;
  font-size: 32px;
  font-weight: 700;
  margin: 0 0 14px 0;
  color: white;
  line-height: 1.25;
}
.hero-banner p {
  font-size: 15px;
  line-height: 1.65;
  max-width: 760px;
  margin: 0;
  color: #cfcfcf;
  font-weight: 400;
}
.hero-banner .byline {
  font-size: 11px;
  color: var(--gold);
  margin-top: 18px;
  text-transform: uppercase;
  letter-spacing: 1.2px;
  border-top: 1px solid rgba(255,255,255,0.15);
  padding-top: 14px;
}

/* --- Stat counters, replacing the old flat-color value boxes with
   something closer to the "100+ Moderated Forums" pattern --- */
.stat-row {
  display: flex;
  background: var(--card-bg);
  border-radius: 4px;
  border: 1px solid var(--hairline);
  padding: 22px 8px;
}
.stat-block {
  flex: 1;
  text-align: center;
  padding: 0 16px;
  border-right: 1px solid var(--hairline);
}
.stat-block:last-child { border-right: none; }
.stat-number {
  font-family: 'Playfair Display', serif;
  font-size: 34px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1;
}
.stat-caption {
  font-size: 11px;
  color: var(--ink-soft);
  text-transform: uppercase;
  letter-spacing: 0.6px;
  margin-top: 8px;
}

/* --- Chart cards: give every panel breathing room, a soft shadow,
   and a quiet gold rule under the title, instead of flexdashboard's
   default boxy borders --- */
.chart-wrapper {
  background: var(--card-bg) !important;
  border: 1px solid var(--hairline) !important;
  border-radius: 6px !important;
  box-shadow: 0 2px 10px rgba(0,0,0,0.03);
}
.chart-title {
  font-family: 'Inter', sans-serif !important;
  font-weight: 600 !important;
  font-size: 14px !important;
  color: var(--ink) !important;
  border-bottom: 2px solid var(--gold) !important;
  padding-bottom: 10px !important;
}
.chart-stage {
  padding: 14px !important;
}

.chart-explainer {
  font-size: 12px;
  color: var(--ink-soft);
  margin: 2px 0 10px 0;
  line-height: 1.5;
  font-style: italic;
}
</style>

Overview
=====================================

Row {data-height=160}
-------------------------------------

### {.no-title data-width=1}

<div class="hero-banner">
<h1>What's Killing Kenyans?</h1>
<p>This dashboard visualises mortality patterns in Kenya using the KNBS Vital Statistics Report 2024,
examining how cause of death varies by sex, age, and county. It highlights three things: men die at
higher rates than women across nearly every age group, the causes that kill differ sharply by life
stage, and fewer than half of Kenya's expected deaths are ever formally registered — with that gap
falling unevenly across counties and by sex.</p>
<div class="byline">By Dannish Odongo &nbsp;·&nbsp; Source: KNBS/CRS Vital Statistics Report 2024</div>
</div>

Row {data-height=130}
-------------------------------------

### {.no-title data-width=1}

```{r}
sex_ratio_national <- round(sum(age_sex_deaths$male) / sum(age_sex_deaths$female) * 100)
```

<div class="stat-row">
<div class="stat-block">
<div class="stat-number">`r comma(kenya_national_completeness$reg_total)`</div>
<div class="stat-caption">Registered deaths, 2024</div>
</div>
<div class="stat-block">
<div class="stat-number">`r sex_ratio_national`</div>
<div class="stat-caption">Male deaths per 100 female</div>
</div>
<div class="stat-block">
<div class="stat-number">`r kenya_national_completeness$comp_total`%</div>
<div class="stat-caption">Share of deaths actually registered</div>
</div>
</div>

Row {data-height=440}
-------------------------------------

### The undercount, in one bar

<div class="chart-explainer">How many of the deaths Kenya expected in 2024 were actually registered, versus how many were never recorded at all.</div>

```{r}
gap_df <- kenya_national_completeness |>
  transmute(
    Registered = reg_total,
    Unregistered = exp_total - reg_total
  ) |>
  pivot_longer(everything(), names_to = "status", values_to = "n") |>
  mutate(status = factor(status, levels = c("Registered", "Unregistered")))

p <- ggplot(gap_df, aes(x = "2024", y = n, fill = status,
                         text = paste0(status, ": ", comma(n)))) +
  geom_col(width = 0.5) +
  geom_text(aes(label = paste0(round(n / sum(n) * 100), "%")),
            position = position_stack(vjust = 0.5),
            color = "white", fontface = "bold", size = 4) +
  scale_fill_manual(values = c("Registered" = "#2b6cb0", "Unregistered" = "#a0aec0")) +
  scale_y_continuous(labels = comma) +
  coord_flip() +
  labs(title = "Fewer than half of Kenya's expected deaths get registered",
       subtitle = "460,585 deaths were expected in 2024; 206,417 were registered",
       x = NULL, y = "Deaths") +
  theme_story() +
  theme(legend.title = element_blank())

ggplotly(p, tooltip = "text") |>
  layout(hovermode = "closest") |>
  config(displayModeBar = FALSE)
```

### National completeness trend, 2020-2024

<div class="chart-explainer">The share of Kenya's expected deaths that were actually registered, tracked year by year.</div>

```{r}
trend_national <- county_completeness_trend |> filter(county == "Kenya")

p <- ggplot(trend_national, aes(x = year, y = completeness_pct,
                                 text = paste0(year, ": ", completeness_pct, "%"))) +
  geom_hline(yintercept = trend_national$completeness_pct[trend_national$year == 2020],
             linetype = "dotted", color = "grey70") +
  geom_line(color = "#2b6cb0", linewidth = 1) +
  geom_point(color = "#2b6cb0", size = 2) +
  scale_y_continuous(limits = c(0, 60), labels = function(x) paste0(x, "%")) +
  labs(title = "A 2021 spike, then a slide back",
       subtitle = "Death registration completeness, Kenya, 2020-2024 (dotted line = 2020 level)",
       x = NULL, y = "Completeness (%)") +
  theme_story()

ggplotly(p, tooltip = "text") |> config(displayModeBar = FALSE)
```

Row {data-height=40}
-------------------------------------

### {.no-title}

<div style="font-size:11px; color:#718096; padding: 4px 8px;">
Note: figures on this page describe <b>registered</b> deaths in Kenya's civil registration system.
As the charts above show, that's fewer than half of all deaths estimated to have occurred — treat
these as a subset the system captured, not a full national total.
</div>

Age & Sex
=====================================

Row {data-height=500}
-------------------------------------

### Where the gap between men and women widens: sex ratio at death by age

<div class="chart-explainer">For every 100 women who died in a given age group, how many men died. 100 means equal numbers of male and female deaths; above 100 means more men died.</div>

```{r}
p <- ggplot(age_sex_deaths, aes(x = age_group, y = sex_ratio, group = 1,
                                 text = paste0(age_group, ": ", sex_ratio,
                                               " male deaths per 100 female deaths"))) +
  geom_hline(yintercept = 100, linetype = "dashed", color = "grey60") +
  geom_line(color = "#c05621", linewidth = 1) +
  geom_point(color = "#c05621", size = 2) +
  labs(title = "More men die at almost every age",
       subtitle = "Sex ratio at death by age group, Kenya, 2024 (100 = parity)",
       x = "Age group", y = "Male deaths per 100 female deaths") +
  theme_story() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

ggplotly(p, tooltip = "text") |>
  layout(margin = list(l = 70)) |>
  config(displayModeBar = FALSE)
```

Row {data-height=500}
-------------------------------------

### Same causes, different weight: leading causes of death by sex, 2024

<div class="chart-explainer">The top causes of registered death in 2024, comparing how many men and how many women died of each.</div>

```{r fig.height=6}
dumbbell_df <- causes_dumbbell |>
  filter(!is.na(Male) | !is.na(Female)) |>
  mutate(cause = reorder(cause, gap))

p <- ggplot(dumbbell_df) +
  geom_segment(aes(x = cause, xend = cause, y = Female, yend = Male),
               color = "grey70", linewidth = 1) +
  geom_point(aes(x = cause, y = Male, text = paste0(cause, " (male): ", comma(Male))),
             color = pal_sex["Male"], size = 3) +
  geom_point(aes(x = cause, y = Female, text = paste0(cause, " (female): ", comma(Female))),
             color = pal_sex["Female"], size = 3) +
  coord_flip() +
  scale_y_continuous(labels = comma) +
  labs(title = "Leading causes of registered health-facility deaths, by sex",
       subtitle = "Blue = male, orange = female. A single dot means that cause only made one sex's top 10.",
       x = NULL, y = "Registered deaths, 2024") +
  theme_story()

ggplotly(p, tooltip = "text") |>
  layout(margin = list(l = 160)) |>
  config(displayModeBar = FALSE)
```

Causes by Age
=====================================

Row {data-height=700}
-------------------------------------

### What kills you depends on how old you are: leading causes by age band, 2024

<div class="chart-explainer">The five leading causes of registered death within each age group, from newborns through age 60+. Bar length is number of deaths.</div>

```{r fig.height=8}
top5_by_age <- causes_by_age_band |>
  group_by(age_band) |>
  slice_min(rank, n = 5) |>
  ungroup()

age_bands_ordered <- levels(droplevels(top5_by_age$age_band))

# Build each age band as its own small horizontal bar chart, rather than
# routing a faceted ggplot through ggplotly() -- that combination reliably
# overlaps axis labels between panels. One consistent color keeps the
# palette calm; the panel label already identifies the age band, so a
# fill legend would only repeat information the reader already has.
make_age_panel <- function(band) {
  df <- top5_by_age |>
    filter(age_band == band) |>
    mutate(cause = reorder(cause, deaths))

  plot_ly(df, x = ~deaths, y = ~cause, type = "bar", orientation = "h",
          marker = list(color = "#2b6cb0"),
          text = ~comma(deaths),
          hovertemplate = "%{y}: %{text}<extra></extra>") |>
    layout(
      xaxis = list(title = "", tickfont = list(size = 8)),
      yaxis = list(title = "", tickfont = list(size = 9)),
      annotations = list(list(
        text = paste0("<b>", band, "</b>"), x = 0, y = 1.22,
        xref = "paper", yref = "paper", showarrow = FALSE,
        font = list(size = 12, family = "Inter, sans-serif"), xanchor = "left"
      )),
      margin = list(l = 10, r = 10, t = 45, b = 20)
    )
}

panels <- lapply(age_bands_ordered, make_age_panel)

subplot(panels, nrows = 2, margin = c(0.05, 0.05, 0.13, 0.07), titleX = TRUE) |>
  layout(showlegend = FALSE, height = 820) |>
  config(displayModeBar = FALSE)
```

Geography
=====================================

Row {data-height=600}
-------------------------------------

### Where registration is most and least complete

<div class="chart-explainer">Which counties registered the largest and smallest share of their expected deaths in 2024.</div>

```{r}
# --------------------------------------------------------------
# This map needs a Kenya county boundary file, which is NOT part
# of the KNBS release and must be fetched separately (one-time).
# Two easy options:
#
# 1) geoBoundaries (simplest, no auth):
#    download.file(
#      "https://www.geoboundaries.org/api/current/gbOpen/KEN/ADM1/",
#      destfile = "data/ken_adm1.json"
#    )
#    # then follow the returned "simplifiedGeometryGeoJSON" URL to
#    # get the actual GeoJSON, and save it as data/kenya_counties.geojson
#
# 2) rKenyaCensus package (bundles county shapefiles directly):
#    install.packages("rKenyaCensus")
#    counties_sf <- rKenyaCensus::KenyaCounties_SHP |> sf::st_as_sf()
#
# Once you have counties_sf with a county-name column, run the
# join below. County-name spelling will very likely need manual
# reconciliation -- see the notes at the bottom of data_prep.R.
# --------------------------------------------------------------

geojson_path <- "data/kenya_counties.geojson"

if (file.exists(geojson_path)) {

  counties_sf <- st_read(geojson_path, quiet = TRUE)

  # Adjust "shapeName" to whatever the name column is actually called
  # in your shapefile (inspect with names(counties_sf)).
  counties_sf <- counties_sf |>
    mutate(county_join = shapeName |>
             stringr::str_replace("Murang'a", "Muranga") |>
             stringr::str_replace("Nairobi City", "Nairobi") |>
             stringr::str_replace("Taita/Taveta", "Taita Taveta") |>
             stringr::str_replace("Elgeyo/Marakwet", "Elgeyo Marakwet"))

  map_data <- county_completeness |>
    mutate(county_join = county_clean |>
             stringr::str_replace("Murang'a", "Muranga") |>
             stringr::str_replace("Nairobi City", "Nairobi") |>
             stringr::str_replace("Taita/Taveta", "Taita Taveta") |>
             stringr::str_replace("Elgeyo/Marakwet", "Elgeyo Marakwet"))

  counties_sf <- counties_sf |> left_join(map_data, by = "county_join")

  pal <- colorNumeric("YlOrRd", domain = counties_sf$comp_total, reverse = TRUE)

  leaflet(counties_sf) |>
    addProviderTiles(providers$CartoDB.Positron) |>
    addPolygons(
      fillColor = ~pal(comp_total),
      fillOpacity = 0.8,
      color = "white",
      weight = 1,
      label = ~paste0(county_clean, ": ", comp_total, "% completeness"),
      highlightOptions = highlightOptions(weight = 2, color = "#333", bringToFront = TRUE)
    ) |>
    addLegend(pal = pal, values = ~comp_total, title = "Completeness (%)",
              position = "bottomright")

} else {

  # Fallback so the dashboard still renders before you've added the
  # shapefile. Rather than one long 47-bar chart (which buries the
  # worst-performing counties at the bottom of a scroll), show the
  # 10 lowest and 10 highest side by side -- that's the actual story,
  # and the full table below covers every county for lookup.
  # Built as two independent plotly panels via subplot(), same pattern
  # as the Causes by Age tab, rather than facet_wrap() + ggplotly().
  bottom10 <- county_completeness |> arrange(comp_total) |> slice_head(n = 10) |>
    mutate(county_clean = reorder(county_clean, comp_total))
  top10    <- county_completeness |> arrange(comp_total) |> slice_tail(n = 10) |>
    mutate(county_clean = reorder(county_clean, comp_total))

  panel_lowest <- plot_ly(bottom10, x = ~comp_total, y = ~county_clean,
                           type = "bar", orientation = "h",
                           marker = list(color = "#c05621"),
                           text = ~paste0(comp_total, "%"),
                           hovertemplate = "%{y}: %{text}<extra></extra>") |>
    layout(xaxis = list(title = "Completeness (%)"), yaxis = list(title = ""),
           annotations = list(list(text = "<b>10 lowest</b>", x = 0, y = 1.1,
                                    xref = "paper", yref = "paper",
                                    showarrow = FALSE, xanchor = "left")))

  panel_highest <- plot_ly(top10, x = ~comp_total, y = ~county_clean,
                            type = "bar", orientation = "h",
                            marker = list(color = "#2b6cb0"),
                            text = ~paste0(comp_total, "%"),
                            hovertemplate = "%{y}: %{text}<extra></extra>") |>
    layout(xaxis = list(title = "Completeness (%)"), yaxis = list(title = ""),
           annotations = list(list(text = "<b>10 highest</b>", x = 0, y = 1.1,
                                    xref = "paper", yref = "paper",
                                    showarrow = FALSE, xanchor = "left")))

  subplot(panel_lowest, panel_highest, nrows = 1, margin = 0.08, titleX = TRUE) |>
    layout(showlegend = FALSE,
           title = list(text = "Death registration completeness, county extremes, 2024",
                        font = list(size = 14))) |>
    config(displayModeBar = FALSE)
}
```

Row {data-height=400}
-------------------------------------

### Full county table

<div class="chart-explainer">Every county's registered deaths, expected deaths, and completeness rate. Sortable and searchable.</div>

```{r}
county_completeness |>
  select(County = county_clean,
         `Registered` = reg_total,
         `Expected` = exp_total,
         `Completeness (%)` = comp_total) |>
  arrange(`Completeness (%)`) |>
  datatable(options = list(pageLength = 10, order = list(list(3, "asc"))),
            rownames = FALSE)
```