Data Visualization in R: A Beginner-to-Advanced Guide

From base R plots to publication-ready, interactive, and domain-specific graphics

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.

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)

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
data(mtcars)

# Quick scatterplot
plot(mtcars$wt, mtcars$mpg,
     main = "Weight vs MPG",
     xlab = "Weight (1000 lbs)", ylab = "Miles per gallon",
     pch = 19, col = "steelblue")

Code
# Quick histogram
hist(mtcars$mpg, breaks = 10, col = "grey80",
     main = "Distribution of MPG", xlab = "MPG")

Code
# Boxplot by group
boxplot(mpg ~ cyl, data = mtcars,
        main = "MPG by Cylinder Count", xlab = "Cylinders", ylab = "MPG")

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 = mtcars, aes(x = wt, y = mpg)) +
  geom_point()

Adding more aesthetics

Code
ggplot(mtcars, aes(x = wt, y = mpg, color = factor(cyl), size = hp)) +
  geom_point(alpha = 0.7) +
  labs(title = "Fuel Efficiency vs Weight",
       x = "Weight (1000 lbs)", y = "Miles per Gallon",
       color = "Cylinders", size = "Horsepower")

The core geoms you’ll use constantly

Code
library(patchwork)

p1 <- ggplot(mtcars, aes(wt, mpg)) + geom_point() + labs(title = "geom_point")
p2 <- ggplot(mtcars, aes(factor(cyl))) + geom_bar() + labs(title = "geom_bar")
p3 <- ggplot(mtcars, aes(mpg)) + geom_histogram(bins = 15) + labs(title = "geom_histogram")
p4 <- ggplot(mtcars, aes(factor(cyl), mpg)) + geom_boxplot() + labs(title = "geom_boxplot")
p5 <- ggplot(mtcars, aes(wt, mpg)) + geom_point() + geom_smooth(method = "lm") +
  labs(title = "geom_smooth")
p6 <- ggplot(mtcars, aes(mpg)) + geom_density(fill = "steelblue", alpha = 0.5) +
  labs(title = "geom_density")

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

Practice exercise (Tier 1)

Using mtcars, make a scatterplot of hp vs qsec, colored by whether the car has automatic or manual transmission (am), with a linear trend line per group.

Show solution
ggplot(mtcars, aes(hp, qsec, color = factor(am, labels = c("Automatic", "Manual")))) +
  geom_point() +
  geom_smooth(method = "lm", se = FALSE) +
  labs(color = "Transmission")


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(mtcars, aes(wt, mpg)) +
  geom_point() +
  facet_wrap(~ cyl, labeller = label_both) +
  labs(title = "MPG vs Weight, split by Cylinder Count")

Code
mtcars2 <- mtcars |> mutate(am = factor(am, labels = c("Automatic", "Manual")))

ggplot(mtcars2, aes(wt, mpg)) +
  geom_point() +
  facet_grid(am ~ cyl) +
  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(mtcars, aes(wt, mpg, color = hp)) +
  geom_point(size = 3) +
  scale_color_viridis_c(option = "plasma") +
  scale_x_continuous(breaks = seq(1, 6, 0.5)) +
  scale_y_continuous(labels = scales::label_number(suffix = " mpg"))

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

Code
ggplot(mtcars, aes(disp, mpg)) +
  geom_point() +
  scale_x_log10() +
  labs(title = "Log-scaled x-axis")

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 zero)
  • Qualitative (scale_color_brewer, ggthemes::scale_color_tableau) — unordered categories
Code
ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3) +
  scale_color_brewer(palette = "Dark2") +
  labs(color = "Cylinders")

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(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3) +
  labs(title = "Weight vs MPG", subtitle = "By cylinder count",
       x = "Weight (1000 lbs)", y = "MPG", color = "Cylinders",
       caption = "Source: mtcars 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(mtcars, aes(x = factor(cyl))) +
  geom_bar(fill = "steelblue") +
  coord_flip() +
  labs(title = "coord_flip()", x = "Cylinders", y = "Count")

Code
# Polar coordinates -> pie/donut (use sparingly; bar charts usually communicate better)
df <- mtcars |> count(cyl) |> mutate(cyl = factor(cyl))
ggplot(df, aes(x = "", y = n, fill = cyl)) +
  geom_col(width = 1) +
  coord_polar(theta = "y") +
  theme_void() +
  labs(title = "coord_polar(): use sparingly")

Practice exercise (Tier 2)

Facet the mtcars scatterplot of wt vs mpg by cyl, apply a viridis color scale mapped to hp, and use theme_minimal().

Show solution
ggplot(mtcars, aes(wt, mpg, color = hp)) +
  geom_point(size = 2.5) +
  facet_wrap(~cyl) +
  scale_color_viridis_c() +
  theme_minimal()


Tier 3 — Advanced & Extensions

3.1 Composing multi-panel figures with patchwork

Code
library(patchwork)

a <- ggplot(mtcars, aes(wt, mpg)) + geom_point()
b <- ggplot(mtcars, aes(factor(cyl))) + geom_bar()

(a | b) +
  plot_annotation(title = "Combined figure",
                   tag_levels = "A")   # auto-labels panels A, B

3.2 Avoiding label overlap with ggrepel

Code
library(ggrepel)

mtcars_named <- mtcars |> mutate(model = rownames(mtcars))

ggplot(mtcars_named, aes(wt, mpg, label = model)) +
  geom_point() +
  geom_text_repel(size = 3, max.overlaps = 15) +
  labs(title = "Non-overlapping labels with ggrepel")

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)

set.seed(1)
draws <- data.frame(
  group = rep(c("A", "B", "C"), each = 500),
  value = c(rnorm(500, 5, 1), rnorm(500, 6, 1.5), rnorm(500, 4.5, 0.8))
)

ggplot(draws, aes(x = group, y = value, fill = group)) +
  stat_halfeye(alpha = 0.7) +
  labs(title = "Distributional plot (stat_halfeye)",
       subtitle = "Shows full distribution, not just mean ± SE") +
  theme_minimal() +
  theme(legend.position = "none")

3.4 Highlighting subsets with gghighlight

Code
library(gghighlight)

ggplot(mtcars_named, aes(wt, mpg)) +
  geom_point(size = 3) +
  gghighlight(mpg > 25, label_key = model) +
  labs(title = "Highlighting cars with mpg > 25")

3.5 Interactivity with plotly

Any ggplot2 object can become an interactive HTML widget:

Code
library(plotly)

p <- ggplot(mtcars, aes(wt, mpg, color = factor(cyl), text = rownames(mtcars))) +
  geom_point(size = 3) +
  labs(color = "Cylinders")

ggplotly(p, tooltip = c("text", "wt", "mpg"))

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 (e.g., epi curves over time, before/after comparisons).

Code
library(gganimate)
library(gapminder)  # install.packages("gapminder") if needed

ggplot(gapminder, aes(gdpPercap, lifeExp, size = pop, color = continent)) +
  geom_point(alpha = 0.7) +
  scale_x_log10() +
  labs(title = "Year: {frame_time}") +
  transition_time(year) +
  ease_aes("linear")

(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 the mtcars_named scatterplot from 3.2, add gghighlight() to highlight cars with wt < 2.5, and wrap it in ggplotly() for interactivity.

Show solution
p <- ggplot(mtcars_named, aes(wt, mpg, label = model)) +
  geom_point(size = 3) +
  gghighlight(wt < 2.5)

ggplotly(p)

Tier 4 — Domain-Specific & Publication Workflows

4.1 Spatial data with sf + ggplot2

Code
library(sf)
library(rnaturalearth)

world <- ne_countries(scale = "medium", returnclass = "sf")

ggplot(world) +
  geom_sf(aes(fill = pop_est)) +
  scale_fill_viridis_c(trans = "log10", labels = scales::label_number()) +
  theme_minimal() +
  labs(title = "World population (log scale)", fill = "Population")

geom_sf() understands simple-features geometry directly — no need to manually fortify shapefiles like in older workflows. Pairs naturally with survey-weighted estimates mapped by district, facility catchment areas, etc.

4.2 Forest plots and meta-analysis visuals (metafor, netmeta)

Code
library(metafor)

# Example: dat.bcg is a built-in meta-analysis dataset in metafor
dat <- escalc(measure = "RR", ai = tpos, bi = tneg, ci = cpos, di = cneg,
              data = dat.bcg)
res <- rma(yi, vi, data = dat)
forest(res, slab = paste(dat.bcg$author, dat.bcg$year))

netmeta has its own plotting functions worth knowing: netgraph() (network geometry), forest.netmeta(), and league tables via netleague() — these are usually clearer than trying to force network-meta-analysis results into raw ggplot2.

4.3 Model diagnostics (DHARMa) and marginal effects (ggeffects)

Code
library(DHARMa)
library(lme4)

m <- glmer(vs ~ wt + (1 | cyl), data = mtcars, family = binomial)
plot(simulateResiduals(m))   # DHARMa diagnostic panel

library(ggeffects)
plot(ggpredict(m, terms = "wt")) +
  labs(title = "Predicted probability by weight")

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(mtcars, aes(wt, mpg, color = factor(cyl))) +
  geom_point(size = 3) +
  labs(title = "Custom house theme", color = "Cylinders") +
  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.
  • Dashboards: shiny + ggplot2/plotly for fully interactive apps; Quarto dashboards (format: dashboard) for lighter-weight interactive reports without a live server.
  • Reproducible reporting: parameterized Quarto documents (params: in YAML) to regenerate the same report structure across datasets, sites, or clients — useful for recurring NGO/MoH deliverables.

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 sf maps, metafor/netmeta plots, custom themes, export pipeline ongoing, project-driven

Work through each tier with your own data, not just mtcars — the concepts transfer immediately once you swap in a real dataset.