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.