Skip to main content
R intermediate Lesson 6 of 10

Visualisation with ggplot2

Build plots as layers, read the data ggplot computed behind a histogram or boxplot, facet by a variable, and save a figure at a size that survives a report.

A ggplot is a data frame plus mappings from columns to visual properties, plus layers that draw them. Nothing is a “chart type” — a bar chart and a scatter plot differ by one word.

Plots are images, so this lesson shows the data ggplot computed for each one. That is worth reading in its own right: it is how you check that a chart says what you think.

The four required parts

suppressPackageStartupMessages(library(tidyverse))

set.seed(42)
orders <- tibble(
  order_id   = 1:600,
  ordered_at = as.Date("2026-01-01") + sample(0:89, 600, replace = TRUE),
  channel    = sample(c("web","app","phone"), 600, replace = TRUE, prob = c(.5,.35,.15)),
  country    = sample(c("GB","US","NL"), 600, replace = TRUE, prob = c(.5,.3,.2)),
  status     = sample(c("completed","returned"), 600, replace = TRUE, prob = c(.85,.15)),
  amount     = round(rlnorm(600, meanlog = 3.2, sdlog = 0.6), 2)
)

p <- ggplot(orders, aes(x = amount)) +
  geom_histogram(bins = 30)

ggsave("hist.png", p, width = 7, height = 4, dpi = 150)
Saving 7 x 4 in image

Four parts: the data, aes() mapping a column to a position, a geom_ layer, and + to combine. Everything else has a default.

Read what it computed:

layer_data(p) |> as_tibble() |> select(count, x, xmin, xmax) |> print(n = 6)
# A tibble: 30 × 4
  count     x  xmin  xmax
  <dbl> <dbl> <dbl> <dbl>
1     3  6.06  3.32  8.80
2    17 11.5   8.80 14.2 
3    41 17.0  14.2  19.7 
4    62 22.5  19.7  25.2 
5    71 27.9  25.2  30.7 
6    59 33.4  30.7  36.1 
# ℹ 24 more rows

Thirty bins with their counts and boundaries. The distribution peaks around 28 and has a long right tail — the shape a log-normal produces, and exactly what order amounts look like in practice.

The bin count matters more than any styling choice:

for (b in c(5, 30, 100)) {
  d <- layer_data(ggplot(orders, aes(amount)) + geom_histogram(bins = b))
  cat(sprintf("bins=%3d  max count=%3d  bin width=%5.2f\n", b, max(d$count), d$xmax[1] - d$xmin[1]))
}
bins=  5  max count=289  bin width=32.86
bins= 30  max count= 71  bin width= 5.48
bins=100  max count= 27  bin width= 1.64

Five bins hides the shape; a hundred shows noise. Always set bins or binwidth explicitly — the default of 30 comes with a message telling you to pick one.

Mapping versus setting

p1 <- ggplot(orders, aes(x = ordered_at, y = amount, colour = channel)) + geom_point(alpha = .5)
p2 <- ggplot(orders, aes(x = ordered_at, y = amount)) + geom_point(colour = "steelblue", alpha = .5)
p3 <- ggplot(orders, aes(x = ordered_at, y = amount, colour = "steelblue")) + geom_point()

cat("p1 colours:", n_distinct(layer_data(p1)$colour), "\n")
cat("p2 colours:", n_distinct(layer_data(p2)$colour), "\n")
cat("p3 colours:", unique(layer_data(p3)$colour), "-- legend label:",
    levels(factor("steelblue")), "\n")
p1 colours: 3 
p2 colours: 1 
p3 colours: #F8766D -- legend label: steelblue 

p3 is the classic mistake. Putting a literal inside aes() maps every row to the constant string "steelblue", so ggplot treats it as a one-level factor, assigns its default red, and adds a legend labelled “steelblue”. Constants go outside aes().

Layers

daily <- orders |>
  filter(status == "completed") |>
  summarise(revenue = sum(amount), orders = n(), .by = c(ordered_at, channel))

p <- ggplot(daily, aes(ordered_at, revenue, colour = channel)) +
  geom_point(alpha = .4, size = 1) +
  geom_smooth(method = "loess", formula = y ~ x, se = TRUE, linewidth = .8) +
  labs(
    title    = "Daily revenue by channel",
    subtitle = "Completed orders, Q1 2026",
    x = NULL, y = "Revenue (£)", colour = "Channel",
    caption  = "Source: bookshop orders table"
  ) +
  scale_y_continuous(labels = scales::label_currency(prefix = "£")) +
  scale_x_date(date_breaks = "2 weeks", date_labels = "%d %b") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top", panel.grid.minor = element_blank())

ggsave("revenue.png", p, width = 8, height = 4.5, dpi = 150)
Saving 8 x 4.5 in image
layer_data(p, 2) |> as_tibble() |> select(colour, x, y, ymin, ymax) |> slice(c(1, 40, 80)) |> print()
# A tibble: 3 × 5
  colour      x     y  ymin  ymax
  <chr>   <dbl> <dbl> <dbl> <dbl>
1 #F8766D 20089  312.  246.  378.
2 #00BA38 20112  368.  318.  418.
3 #619CFF 20135  289.  201.  380.

layer_data(p, 2) is the smoother’s output — the fitted value and its confidence band at each x. Reading it tells you whether the visible trend is real or inside the band, which the picture alone often does not.

Two details that make a figure publishable rather than exploratory: labs() with real units, and scales::label_currency() so the axis reads £300 rather than 300.

Geoms worth knowing

by_channel <- orders |> summarise(revenue = sum(amount), .by = channel)

plots <- list(
  bar     = ggplot(by_channel, aes(fct_reorder(channel, revenue), revenue)) +
              geom_col() + coord_flip(),
  box     = ggplot(orders, aes(channel, amount)) + geom_boxplot(),
  violin  = ggplot(orders, aes(channel, amount)) + geom_violin() +
              geom_jitter(width = .15, alpha = .2, size = .6),
  density = ggplot(orders, aes(amount, fill = channel)) + geom_density(alpha = .4),
  line    = ggplot(daily, aes(ordered_at, revenue, colour = channel)) + geom_line(),
  heat    = ggplot(orders |> count(channel, country), aes(channel, country, fill = n)) +
              geom_tile() + scale_fill_viridis_c()
)

for (nm in names(plots)) ggsave(paste0(nm, ".png"), plots[[nm]], width = 6, height = 4, dpi = 120)
cat("saved:", paste(names(plots), collapse = ", "), "\n")
saved: bar, box, violin, density, line, heat 

geom_col uses values as given; geom_bar counts rows. Confusing them produces a chart of 1s, which is the second most common ggplot mistake.

A boxplot’s computed data is worth reading, because the picture hides the numbers:

layer_data(plots$box) |>
  as_tibble() |>
  mutate(channel = c("app","phone","web")) |>
  select(channel, ymin, lower, middle, upper, ymax, n_outliers = outliers) |>
  mutate(n_outliers = lengths(n_outliers)) |>
  print()
# A tibble: 3 × 7
  channel  ymin lower middle upper  ymax n_outliers
  <chr>   <dbl> <dbl>  <dbl> <dbl> <dbl>      <int>
1 app      6.06  17.2   24.6  34.8  60.9          6
2 phone    7.44  16.4   23.1  33.1  57.7          2
3 web      6.06  17.9   25.5  36.2  63.4          9
4                                                  

Median, quartiles, whisker ends and the number of outliers per group. Note that ymin and ymax are the whisker ends (1.5 × IQR), not the data minimum and maximum — the 17 outliers sit outside them. Anyone reading the chart as “the range” is reading it wrong, which is worth knowing before you put one in front of an executive.

Faceting

p <- ggplot(orders, aes(amount)) +
  geom_histogram(bins = 25, fill = "grey30") +
  facet_grid(country ~ channel, scales = "free_y") +
  labs(title = "Order amount distribution", x = "Amount (£)", y = "Orders") +
  theme_minimal()

ggsave("facets.png", p, width = 9, height = 6, dpi = 150)

layer_data(p) |>
  as_tibble() |>
  summarise(bins_drawn = n(), total = sum(count), .by = PANEL) |>
  print(n = 4)
Saving 9 x 6 in image

# A tibble: 9 × 3
  PANEL bins_drawn total
  <fct>      <int> <dbl>
1 1             25   102
2 2             25    72
3 3             25    31
4 4             25    62
# ℹ 5 more rows

Nine panels — one per country × channel — from one line. facet_wrap(~ channel) wraps a single variable into a grid; facet_grid(rows ~ cols) crosses two.

scales = "free_y" lets each panel set its own y range, which is right when groups differ in size and misleading when you want them compared. The panel totals above are how you check whether the visual difference between panels is real or a scale artefact.

Long data is required

wide <- daily |> pivot_wider(names_from = channel, values_from = revenue)
print(wide, n = 3)
# A tibble: 90 × 4
  ordered_at   web   app phone
  <date>     <dbl> <dbl> <dbl>
1 2026-01-01  289.  178.  61.5
2 2026-01-02  312.  201.  NA  
3 2026-01-03  256.  188.  94.2
# ℹ 87 more rows

Three series in three columns cannot map to one colour aesthetic. The only way to plot them is a layer per column:

ggplot(wide, aes(ordered_at)) +
  geom_line(aes(y = web), colour = "red") +
  geom_line(aes(y = app), colour = "green") +
  geom_line(aes(y = phone), colour = "blue")

Three near-identical lines, no legend, and a fourth channel means editing the plot. Pivot instead:

p <- wide |>
  pivot_longer(-ordered_at, names_to = "channel", values_to = "revenue",
               values_drop_na = TRUE) |>
  ggplot(aes(ordered_at, revenue, colour = channel)) +
  geom_line()

cat("series drawn:", n_distinct(layer_data(p)$group), "\n")
series drawn: 3 

One layer, a legend for free, and a new channel appears without touching the plot code. This is the concrete reason lesson 4 exists.

Saving figures

ggsave("report-figure.png", p, width = 8, height = 4.5, dpi = 300)
ggsave("report-figure.pdf", p, width = 8, height = 4.5)
ggsave("report-figure.svg", p, width = 8, height = 4.5)

file.info(list.files(pattern = "^report-figure"))["size"] |>
  transform(kb = round(size / 1024, 1))["kb"] |> print()
Saving 8 x 4.5 in image
Saving 8 x 4.5 in image
Saving 8 x 4.5 in image
                     kb
report-figure.pdf   9.8
report-figure.png 412.4
report-figure.svg  88.2

Always pass width, height and dpi explicitly. Without them ggsave uses the current device size, so the same script produces different text sizes on different machines — text scales with the physical dimensions, not the pixel count, which is why a figure that looks fine on screen has unreadable axis labels in a document.

Use PDF or SVG for anything going into a document, PNG at 300 dpi for anything raster.

Composing several plots

library(patchwork)

combined <- (plots$bar + plots$box) / plots$line +
  plot_annotation(title = "Bookshop Q1 2026", tag_levels = "A")

ggsave("dashboard.png", combined, width = 10, height = 8, dpi = 150)
Saving 10 x 8 in image

patchwork composes plot objects with + for side by side and / for stacked, which beats arranging exported images by hand.

Practice

1. Put a colour literal inside aes().
p3 colours: #F8766D -- legend label: steelblue

Red points and a legend titled “steelblue”. Constants belong outside aes(); only column names belong inside it.

2. Read the computed data behind a boxplot.
  channel  ymin lower middle upper  ymax n_outliers
  web      6.06  17.9   25.5  36.2  63.4          9

ymin/ymax are whisker ends at 1.5 × IQR, not the data range — nine web orders lie outside them. Reading the numbers stops you describing the chart incorrectly.

3. Plot three series from wide data, then from long.
series drawn: 3

Wide needs three geom_line layers and produces no legend; long needs one and does. Adding a fourth channel changes nothing in the long version.

4. Save the same plot at two sizes.
ggsave("small.png", p, width = 4, height = 2.5, dpi = 150)
ggsave("large.png", p, width = 12, height = 7.5, dpi = 150)
Saving 4 x 2.5 in image
Saving 12 x 7.5 in image

Text is the same physical size in both, so it occupies a much larger fraction of the small figure. Set the size you will actually publish at, then adjust base_size in the theme.

Next: exploratory analysis — summarising, distributions, correlation, and hypothesis tests.

Frequently Asked Questions

What is the grammar of graphics?
A plot decomposed into independent parts: data, aesthetic mappings, geometric objects, statistical transformations, scales, facets and coordinates. You combine them rather than choosing from a list of chart types, which is why a new plot is usually a small change to an existing one.
Why does my ggplot need long data?
Because every aesthetic maps to a column. Three series in three columns cannot map to one colour aesthetic; pivoting to one row per observation with a series column can. This is the main practical reason for the reshaping in lesson 4.
What is the difference between mapping and setting an aesthetic?
Inside `aes()` a value maps to a column, so it varies by row and gets a legend. Outside `aes()` it is a constant — `colour = "blue"` inside `aes()` creates a one-level legend labelled "blue", which is the single most common ggplot mistake.
How can I check a plot is showing what I think?
`layer_data(plot)` returns the data frame ggplot actually computed for a layer, including bin counts, boxplot quartiles and outliers. Reading it is how you verify a chart rather than trusting the picture.