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 scatterplotplot(mtcars$wt, mtcars$mpg,main ="Weight vs MPG",xlab ="Weight (1000 lbs)", ylab ="Miles per gallon",pch =19, col ="steelblue")
Code
# Quick histogramhist(mtcars$mpg, breaks =10, col ="grey80",main ="Distribution of MPG", xlab ="MPG")
Code
# Boxplot by groupboxplot(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:
Data — a data frame
Aesthetic mappings (aes()) — which columns map to which visual properties (x, y, color, size, shape…)
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")
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.
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 labelsggplot(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().
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")
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 metafordat <-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 panellibrary(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/slidesggsave("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.
Source Code
---title: "Data Visualization in R: A Beginner-to-Advanced Guide"subtitle: "From base R plots to publication-ready, interactive, and domain-specific graphics"author: "Timothy Achala"format: html: toc: true toc-depth: 4 toc-location: left code-fold: show code-tools: true theme: cosmo fig-width: 7 fig-height: 4.5execute: warning: false message: false---## How to use this guideThis document is organized in four tiers: **Foundations**, **Intermediate ggplot2**,**Advanced & Extensions**, and **Domain-specific / Publication workflows**. Eachsection builds on the last. Work through it top to bottom, or jump to a tier ifyou're already comfortable with the basics. Every code chunk is runnable as-is inQuarto (Render button, or `Cmd/Ctrl+Shift+K`), provided the listed packages areinstalled.```{r setup, message=FALSE, warning=FALSE}# 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. Thatgrammar (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 whatmakes 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 otherpeople's code, so it's worth recognizing even if you do most of your work in`ggplot2`.```{r}data(mtcars)# Quick scatterplotplot(mtcars$wt, mtcars$mpg,main ="Weight vs MPG",xlab ="Weight (1000 lbs)", ylab ="Miles per gallon",pch =19, col ="steelblue")# Quick histogramhist(mtcars$mpg, breaks =10, col ="grey80",main ="Distribution of MPG", xlab ="MPG")# Boxplot by groupboxplot(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 fundamentalsEvery `ggplot2` plot has three required pieces:1. **Data** — a data frame2. **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...)```{r}ggplot(data = mtcars, aes(x = wt, y = mpg)) +geom_point()```### Adding more aesthetics```{r}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```{r}#| fig-height: 6library(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.```{r}#| code-fold: true#| code-summary: "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 multiplesFacets split one plot into a grid of panels by a categorical variable — oftenclearer than cramming everything into color/shape.```{r}ggplot(mtcars, aes(wt, mpg)) +geom_point() +facet_wrap(~ cyl, labeller = label_both) +labs(title ="MPG vs Weight, split by Cylinder Count")``````{r}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 visualsScales control axis breaks, color palettes, and transformations.```{r}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):```{r}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```{r}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/forcolorblind viewers. `viridis` palettes are colorblind-safe by design.## 2.4 Themes: polishing the look```{r}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()``````{r}# 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```{r}# Flipped bar chart — useful for long category labelsggplot(mtcars, aes(x =factor(cyl))) +geom_bar(fill ="steelblue") +coord_flip() +labs(title ="coord_flip()", x ="Cylinders", y ="Count")``````{r}# 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()`.```{r}#| code-fold: true#| code-summary: "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````{r}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````{r}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` makesdistributional visualization (posterior draws, confidence bands, bootstrapdistributions) straightforward.```{r}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````{r}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:```{r}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 staticprint/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).```{r}#| eval: falselibrary(gganimate)library(gapminder) # install.packages("gapminder") if neededggplot(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 onwhen 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.```{r}#| code-fold: true#| code-summary: "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````{r}#| eval: falselibrary(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 tomanually 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`)```{r}#| eval: falselibrary(metafor)# Example: dat.bcg is a built-in meta-analysis dataset in metafordat <-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 forcenetwork-meta-analysis results into raw `ggplot2`.## 4.3 Model diagnostics (`DHARMa`) and marginal effects (`ggeffects`)```{r}#| eval: falselibrary(DHARMa)library(lme4)m <-glmer(vs ~ wt + (1| cyl), data = mtcars, family = binomial)plot(simulateResiduals(m)) # DHARMa diagnostic panellibrary(ggeffects)plot(ggpredict(m, terms ="wt")) +labs(title ="Predicted probability by weight")````ggeffects` output is a `ggplot2` object, so every theming/scale trick aboveapplies 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 aproject or client report is consistent.```{r}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```{r}#| eval: false# 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/slidesggsave("figure1.png", plot = p, width =7, height =4.5, dpi =300)```In Quarto specifically, set `fig-width`, `fig-height`, and `fig-dpi` in theYAML header (as done at the top of this document) so every figure in therendered 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` — theconcepts transfer immediately once you swap in a real dataset.