Data Visualization in R

Using diabetes-clinic dataset

Author

Timothy Achala

How to use this guide

This document is organized in four tiers: Foundations, Intermediate ggplot2, Advanced & Extensions, and Domain-specific / Publication workflows. Each section builds on the last. Work through it top to bottom, or jump to a tier if you’re already comfortable with the basics. Every code chunk is runnable as-is in Quarto (Render button, or Cmd/Ctrl+Shift+K), provided the listed packages are installed and clinic_diabetes_simulated.csv sits in the same folder as this .qmd file.

The dataset

clinic_diabetes_simulated.csv is a fully simulated 900-row dataset modeled loosely on a diabetes-management program across six counties in western Kenya. It is not real patient data — it exists to give this guide variables worth plotting: a date, a few categoricals, several continuous measures, and a binary outcome.

Column Type Description
patient_id character Unique patient identifier
visit_date date Visit date, 2023–2025
region categorical County (6 levels)
facility categorical Health facility (nested in region)
sex categorical Female / Male
age continuous Years
bmi continuous Body mass index
treatment_group categorical 4-arm treatment assignment
hba1c continuous Glycated hemoglobin (%)
systolic_bp continuous Systolic blood pressure (a few missing values, by design)
diastolic_bp continuous Diastolic blood pressure
outcome_status categorical Controlled / Uncontrolled (HbA1c < 7.0 threshold)
cost_kes continuous Simulated visit cost, Kenyan shillings
Code
# Run once if you don't have these installed:
# install.packages(c("tidyverse", "scales", "patchwork", "ggrepel",
#                     "ggdist", "gghighlight", "gganimate", "plotly",
#                     "sf", "rnaturalearth", "viridis", "ggthemes"))

library(ggplot2)
library(dplyr)
library(readr)

clinic <- read_csv("clinic_diabetes_simulated.csv", show_col_types = FALSE) |>
  mutate(
    region = factor(region),
    treatment_group = factor(treatment_group),
    outcome_status = factor(outcome_status, levels = c("Uncontrolled", "Controlled")),
    sex = factor(sex)
  )

glimpse(clinic)
Rows: 900
Columns: 13
$ patient_id      <chr> "P0001", "P0002", "P0003", "P0004", "P0005", "P0006", …
$ visit_date      <date> 2023-06-28, 2023-11-23, 2025-07-29, 2023-06-13, 2023-…
$ region          <fct> Siaya, Siaya, Kakamega, Trans Nzoia, Bungoma, Vihiga, …
$ facility        <chr> "Siaya County Hospital", "Bondo Sub-County Hospital", …
$ sex             <fct> Female, Female, Female, Male, Female, Female, Female, …
$ age             <dbl> 44, 35, 56, 28, 35, 36, 60, 45, 35, 18, 56, 46, 38, 54…
$ bmi             <dbl> 30.6, 18.7, 21.5, 30.0, 19.6, 31.1, 34.0, 28.4, 25.7, …
$ treatment_group <fct> Lifestyle Counseling, Lifestyle Counseling, Lifestyle …
$ hba1c           <dbl> 5.43, 4.80, 4.80, 5.32, 4.80, 5.18, 5.07, 4.80, 4.80, …
$ systolic_bp     <dbl> 132, 104, 111, 100, 106, 150, 139, 150, 138, 118, 133,…
$ diastolic_bp    <dbl> 82, 87, 83, 73, 77, 84, 107, 58, 78, 94, 78, 74, 84, 7…
$ outcome_status  <fct> Controlled, Controlled, Controlled, Controlled, Contro…
$ cost_kes        <dbl> 594, 1216, 932, 2019, 1072, 1441, 1514, 703, 1484, 117…

Tier 1 — Foundations

1.1 Why R for visualization?

R treats a plot as data + a grammar, not a drawing you click together. That grammar (the “grammar of graphics”, implemented by ggplot2) means you describe what the plot should show, and R figures out how to draw it. This is what makes R plots so reproducible and easy to iterate on.

1.2 Base R plotting (know it, don’t live in it)

Base R plotting is fast for a quick look at data. You’ll see it in other people’s code, so it’s worth recognizing even if you do most of your work in ggplot2.

Code
# Quick scatterplot
plot(clinic$bmi, clinic$hba1c,
     main = "BMI vs HbA1c", xlab = "BMI", ylab = "HbA1c (%)",
     pch = 19, col = "steelblue")

Code
# Quick histogram
hist(clinic$age, breaks = 15, col = "grey80",
     main = "Distribution of Patient Age", xlab = "Age")

Code
# Boxplot by group
boxplot(hba1c ~ treatment_group, data = clinic,
        main = "HbA1c by Treatment Group", xlab = "", ylab = "HbA1c (%)",
        las = 2)

When base R is fine: fast exploratory checks while cleaning data. When to switch to ggplot2: anything you’ll show someone else, iterate on, or reuse.

1.3 The Grammar of Graphics: ggplot2 fundamentals

Every ggplot2 plot has three required pieces:

  1. Data — a data frame
  2. Aesthetic mappings (aes()) — which columns map to which visual properties (x, y, color, size, shape…)
  3. Geometry (geom_*()) — the type of visual mark (points, bars, lines…)
Code
ggplot(data = clinic, aes(x = bmi, y = hba1c)) +
  geom_point()

Adding more aesthetics

Code
ggplot(clinic, aes(x = bmi, y = hba1c, color = treatment_group, size = age)) +
  geom_point(alpha = 0.6) +
  labs(title = "HbA1c vs BMI by Treatment",
       x = "BMI", y = "HbA1c (%)",
       color = "Treatment", size = "Age")

The core geoms you’ll use constantly

Code
library(patchwork)

p1 <- ggplot(clinic, aes(bmi, hba1c)) + geom_point(alpha = 0.4) + labs(title = "geom_point")
p2 <- ggplot(clinic, aes(region)) + geom_bar() + coord_flip() + labs(title = "geom_bar")
p3 <- ggplot(clinic, aes(hba1c)) + geom_histogram(bins = 30) + labs(title = "geom_histogram")
p4 <- ggplot(clinic, aes(treatment_group, hba1c)) + geom_boxplot() +
  labs(title = "geom_boxplot") + theme(axis.text.x = element_text(angle = 30, hjust = 1))
p5 <- ggplot(clinic, aes(bmi, hba1c)) + geom_point(alpha = 0.3) +
  geom_smooth(method = "lm") + labs(title = "geom_smooth")
p6 <- ggplot(clinic, aes(cost_kes)) + geom_density(fill = "steelblue", alpha = 0.5) +
  labs(title = "geom_density")

(p1 | p2 | p3) / (p4 | p5 | p6)

Practice exercise (Tier 1)

Make a scatterplot of age vs systolic_bp, colored by sex, with a linear trend line per group.

Show solution
ggplot(clinic, aes(age, systolic_bp, color = sex)) +
  geom_point(alpha = 0.5) +
  geom_smooth(method = "lm", se = FALSE) +
  labs(x = "Age", y = "Systolic BP", color = "Sex")


Tier 2 — Intermediate ggplot2

2.1 Facets: small multiples

Facets split one plot into a grid of panels by a categorical variable — often clearer than cramming everything into color/shape.

Code
ggplot(clinic, aes(bmi, hba1c)) +
  geom_point(alpha = 0.4) +
  facet_wrap(~ region) +
  labs(title = "HbA1c vs BMI, split by Region")

Code
ggplot(clinic, aes(bmi, hba1c)) +
  geom_point(alpha = 0.4) +
  facet_grid(sex ~ outcome_status) +
  labs(title = "facet_grid: two variables at once")

2.2 Scales: controlling how data maps to visuals

Scales control axis breaks, color palettes, and transformations.

Code
ggplot(clinic, aes(bmi, hba1c, color = age)) +
  geom_point(size = 2) +
  scale_color_viridis_c(option = "plasma") +
  scale_x_continuous(breaks = seq(15, 50, 5)) +
  scale_y_continuous(labels = scales::label_number(suffix = "%"))

Log scales are common for skewed data (cost, income, viral load):

Code
ggplot(clinic, aes(cost_kes)) +
  geom_histogram(bins = 30) +
  scale_x_log10(labels = scales::label_number(big.mark = ",")) +
  labs(title = "Log-scaled cost distribution", x = "Cost (KES, log scale)")

2.3 Color: palettes that communicate

  • Sequential (viridis, scale_color_gradient) — ordered numeric data
  • Diverging (scale_color_gradient2) — data with a meaningful midpoint (e.g., above/below a threshold)
  • Qualitative (scale_color_brewer, ggthemes::scale_color_tableau) — unordered categories
Code
ggplot(clinic, aes(bmi, hba1c, color = treatment_group)) +
  geom_point(size = 2, alpha = 0.6) +
  scale_color_brewer(palette = "Dark2") +
  labs(color = "Treatment")

Rule of thumb: always check your plot renders sensibly in grayscale/for colorblind viewers. viridis palettes are colorblind-safe by design.

2.4 Themes: polishing the look

Code
p <- ggplot(clinic, aes(bmi, hba1c, color = treatment_group)) +
  geom_point(size = 2, alpha = 0.6) +
  labs(title = "HbA1c vs BMI", subtitle = "By treatment group",
       x = "BMI", y = "HbA1c (%)", color = "Treatment",
       caption = "Source: simulated clinic dataset")

p + theme_minimal()

Code
# Custom theme tweaks — this is how you build a "house style"
p + theme_minimal(base_size = 13) +
  theme(
    plot.title = element_text(face = "bold"),
    legend.position = "bottom",
    panel.grid.minor = element_blank()
  )

2.5 Coordinate systems

Code
# Flipped bar chart — useful for long category labels
ggplot(clinic, aes(x = facility)) +
  geom_bar(fill = "steelblue") +
  coord_flip() +
  labs(title = "coord_flip()", x = NULL, y = "Visits")

Code
# Polar coordinates -> donut (use sparingly; bar charts usually communicate better)
df <- clinic |> count(outcome_status)
ggplot(df, aes(x = 2, y = n, fill = outcome_status)) +
  geom_col(width = 1) +
  coord_polar(theta = "y") +
  xlim(0.5, 2.5) +
  theme_void() +
  labs(title = "coord_polar(): use sparingly", fill = "Outcome")

Practice exercise (Tier 2)

Facet the bmi vs hba1c scatterplot by treatment_group, apply a viridis color scale mapped to age, and use theme_minimal().

Show solution
ggplot(clinic, aes(bmi, hba1c, color = age)) +
  geom_point(size = 2, alpha = 0.6) +
  facet_wrap(~treatment_group) +
  scale_color_viridis_c() +
  theme_minimal()


Tier 3 — Advanced & Extensions

3.1 Composing multi-panel figures with patchwork

Code
library(patchwork)

a <- ggplot(clinic, aes(bmi, hba1c)) + geom_point(alpha = 0.4)
b <- ggplot(clinic, aes(treatment_group)) + geom_bar() +
  theme(axis.text.x = element_text(angle = 30, hjust = 1))

(a | b) +
  plot_annotation(title = "Combined figure", tag_levels = "A")

3.2 Avoiding label overlap with ggrepel

Code
library(ggrepel)

facility_means <- clinic |>
  group_by(facility) |>
  summarise(mean_bmi = mean(bmi), mean_hba1c = mean(hba1c), .groups = "drop")

ggplot(facility_means, aes(mean_bmi, mean_hba1c, label = facility)) +
  geom_point() +
  geom_text_repel(size = 3, max.overlaps = 15) +
  labs(title = "Facility-level averages", x = "Mean BMI", y = "Mean HbA1c")

3.3 Visualizing uncertainty with ggdist

Point estimates without uncertainty are half a story. ggdist makes distributional visualization (posterior draws, confidence bands, bootstrap distributions) straightforward.

Code
library(ggdist)

ggplot(clinic, aes(x = treatment_group, y = hba1c, fill = treatment_group)) +
  stat_halfeye(alpha = 0.7) +
  labs(title = "HbA1c distribution by treatment (stat_halfeye)",
       subtitle = "Shows full distribution, not just mean ± SE") +
  theme_minimal() +
  theme(legend.position = "none",
        axis.text.x = element_text(angle = 30, hjust = 1))

3.4 Highlighting subsets with gghighlight

Code
library(gghighlight)

ggplot(facility_means, aes(mean_bmi, mean_hba1c)) +
  geom_point(size = 3) +
  gghighlight(mean_hba1c > 6.2, label_key = facility) +
  labs(title = "Facilities with mean HbA1c above 6.2%")

3.5 Interactivity with plotly

Any ggplot2 object can become an interactive HTML widget:

Code
library(plotly)

p <- ggplot(clinic, aes(bmi, hba1c, color = treatment_group,
                          text = paste(patient_id, "-", region))) +
  geom_point(size = 2, alpha = 0.6) +
  labs(color = "Treatment")

ggplotly(p, tooltip = c("text", "bmi", "hba1c"))

Use interactivity for exploratory dashboards or web reports — not for static print/PDF output, where it silently falls back to an image.

3.6 Animation with gganimate

Useful for time-series or process data — here, average HbA1c drifting down over the study period as treatment effects accumulate.

Code
library(gganimate)

monthly <- clinic |>
  mutate(month = as.Date(cut(visit_date, "month"))) |>
  group_by(month, treatment_group) |>
  summarise(mean_hba1c = mean(hba1c), .groups = "drop")

ggplot(monthly, aes(month, mean_hba1c, color = treatment_group)) +
  geom_line(linewidth = 1) +
  geom_point(size = 2) +
  labs(title = "Mean HbA1c over time", x = NULL, y = "Mean HbA1c (%)") +
  transition_reveal(month)

(Chunk set to eval: false since gganimate rendering is slow — flip it on when you’re ready to render the .gif.)

Practice exercise (Tier 3)

Take facility_means from 3.2, add gghighlight() to highlight facilities with mean_bmi > 28, and wrap it in ggplotly() for interactivity.

Show solution
p <- ggplot(facility_means, aes(mean_bmi, mean_hba1c, label = facility)) +
  geom_point(size = 3) +
  gghighlight(mean_bmi > 28)

ggplotly(p)

Tier 4 — Domain-Specific & Publication Workflows

4.2 Regional comparison: ordered bar/point charts

Code
region_summary <- clinic |>
  group_by(region) |>
  summarise(pct_controlled = mean(outcome_status == "Controlled") * 100, .groups = "drop")

ggplot(region_summary, aes(x = reorder(region, pct_controlled), y = pct_controlled)) +
  geom_col(fill = "steelblue") +
  coord_flip() +
  labs(title = "Percent of patients with controlled HbA1c, by region",
       x = NULL, y = "% Controlled") +
  theme_minimal()

Reordering categories by value (reorder()) rather than leaving them alphabetical is a small change that makes bar charts dramatically easier to read — always do this unless there’s a natural inherent order (e.g., age bands, dates).

4.3 Model diagnostics and marginal effects (conceptual pattern)

For real project work you’d fit a model first — e.g. a logistic GLMM of outcome_status on treatment_group with a random intercept for facility (mirroring your lme4/glmmTMB workflow), then visualize it:

Code
library(lme4)
library(DHARMa)
library(ggeffects)

m <- glmer(outcome_status ~ treatment_group + bmi + age + (1 | facility),
           data = clinic, family = binomial)

plot(simulateResiduals(m))            # DHARMa diagnostic panel
plot(ggpredict(m, terms = "bmi"))     # marginal effect, already a ggplot object

ggeffects output is a ggplot2 object, so every theming/scale trick above applies directly to marginal-effects plots.

4.4 Building and reusing a custom theme (house style)

Once you have a look you like, wrap it in a function so every plot in a project or client report is consistent.

Code
theme_myreport <- function(base_size = 12) {
  theme_minimal(base_size = base_size) +
    theme(
      plot.title = element_text(face = "bold", size = rel(1.2)),
      plot.subtitle = element_text(color = "grey40"),
      legend.position = "bottom",
      panel.grid.minor = element_blank(),
      strip.background = element_rect(fill = "grey90", color = NA)
    )
}

ggplot(clinic, aes(bmi, hba1c, color = treatment_group)) +
  geom_point(size = 2, alpha = 0.6) +
  labs(title = "Custom house theme", color = "Treatment") +
  theme_myreport()

4.5 Exporting for print vs. web

Code
# Vector format for print/publication (scales cleanly, editable in Illustrator/Inkscape)
ggsave("figure1.pdf", plot = p, width = 7, height = 4.5, units = "in")

# High-res raster for web/slides
ggsave("figure1.png", plot = p, width = 7, height = 4.5, dpi = 300)

In Quarto specifically, set fig-width, fig-height, and fig-dpi in the YAML header (as done at the top of this document) so every figure in the rendered report is consistent without per-chunk overrides.

4.6 Where to go from here

  • ggplot2 internals: read Hadley Wickham’s ggplot2: Elegant Graphics for Data Analysis (free online) to understand stat_* vs geom_* and writing your own geom/stat.
  • Spatial extension: swap region in this dataset for real county boundary shapefiles (sf) and map pct_controlled as a choropleth — a natural next step given this data’s geographic structure.
  • Meta-analysis extension: treat each facility as a “study” and each treatment_group effect as an effect size, then practice forest plots with metafor/netmeta on the aggregated facility_means-style table.
  • Dashboards: shiny + ggplot2/plotly, or a Quarto dashboard (format: dashboard), to let others filter this dataset by region or treatment interactively.
  • Reproducible reporting: parameterized Quarto documents (params: in YAML) to regenerate the same report structure across datasets, sites, or clients.

Suggested learning path

Stage Focus Time estimate
Tier 1 aes(), core geoms, basic labs/titles 1 week
Tier 2 facets, scales, color, themes, coords 1–2 weeks
Tier 3 patchwork, ggrepel, ggdist, plotly, gganimate 2–3 weeks
Tier 4 time trends, regional comparisons, model-diagnostic plots, custom themes, export pipeline ongoing, project-driven

Work through each tier with this dataset, then repeat with your own — the concepts transfer immediately once real variables replace the simulated ones.