6  Data visualization

Julia has three reasonable routes to graphics, all resting on Makie.jl (Danisch and Krumbiegel 2021):

Route For whom
TidierPlots.jl people coming from ggplot2 who want the same grammar
AlgebraOfGraphics.jl people who want an idiomatically Julian grammar of graphics
Plain Makie.jl full control over the figure

This chapter shows all three on the same data, because the right choice depends on the task — and because knowing how to translate between them keeps you from getting stuck.

using TidierData
using CairoMakie
using AlgebraOfGraphics
using CSV, DataFrames

CairoMakie.activate!(type = "png")

ponds = CSV.read(joinpath("..", "data", "ponds.csv"), DataFrame)
ponds_c = @chain ponds begin
    @drop_missing(canopy)
    @mutate(log_area = log(area_m2))
end

nrow(ponds_c)
173
NoteExercises in this chapter

A figure cannot be checked automatically, so in most Your turn boxes you compare your plot with the one in the solution. Start each session by loading the packages and the data, with the book folder open in VS Code:

include("exercises/check.jl")
using TidierData, TidierPlots, CairoMakie, AlgebraOfGraphics, CSV, DataFrames, Statistics
ponds = CSV.read(joinpath("data", "ponds.csv"), DataFrame)
ponds_c = @chain ponds begin
    @drop_missing(canopy)
    @mutate(log_area = log(area_m2))
end

6.1 TidierPlots.jl: the ggplot grammar

using TidierPlots

ggplot(ponds_c, @aes(x = canopy, y = richness, color = region)) +
    geom_point(alpha = 0.7) +
    labs(x = "Canopy cover (%)", y = "Species richness",
         title = "Richness declines with shading")
Figure 6.1: Richness against canopy cover, by region.

In R

ggplot(ponds_c, aes(x = canopy, y = richness, colour = region)) +
  geom_point(alpha = 0.7) +
  labs(x = "Canopy cover (%)", y = "Species richness")

The translation is nearly character for character; the difference is the @ in @aes.

NoteYour turn 6.1 — Richness and temperature

Redo Figure 6.1 with water temperature (temp_c) on the x axis and the points coloured by hydroperiod (hydro). Label both axes. Does richness change with temperature as clearly as with canopy?

There is no checker for this one: compare your figure with the one in the solution.

Only the @aes mapping and the labels change.

ggplot(ponds_c, @aes(x = temp_c, y = richness, color = hydro)) +
    geom_point(alpha = 0.7) +
    labs(x = "Water temperature (°C)", y = "Species richness")

The cloud is much flatter than for canopy. The simulation does include a temperature effect (scripts/make_data.jl), but a small one: about 3% more species per degree, invisible next to the scatter between ponds.

6.2 AlgebraOfGraphics.jl: an algebraic grammar

AoG treats plots as an algebra: * combines layers, + superimposes them.

plt = AlgebraOfGraphics.data(ponds_c) *
      mapping(:canopy => "Canopy cover (%)",
              :richness => "Species richness",
              color = :region => "Region") *
      (visual(Scatter, alpha = 0.6) + AlgebraOfGraphics.linear())

draw(plt; figure = (size = (700, 450),))
Figure 6.2: The same plot in AlgebraOfGraphics, with a linear fit per region.
Note

AlgebraOfGraphics.data and AlgebraOfGraphics.linear are qualified because the names data and linear collide with other loaded packages. In a script using only AoG, data(...) and linear() suffice.

NoteYour turn 6.2 — Body size, with fitted lines

With AlgebraOfGraphics, plot body_size against temp_c, coloured by hydro, with a linear fit for each hydroperiod. Do permanent and temporary ponds differ?

There is no checker for this one: compare your figure with the one in the solution.

Start from the block above and change the mapping. The fitted lines follow the colour grouping automatically.

plt = AlgebraOfGraphics.data(ponds_c) *
      mapping(:temp_c => "Water temperature (°C)",
              :body_size => "Body size (mm)",
              color = :hydro => "Hydroperiod") *
      (visual(Scatter, alpha = 0.6) + AlgebraOfGraphics.linear())

draw(plt; figure = (size = (700, 450),))

The two lines almost coincide: in the simulation, body size depends on temperature and canopy but not on hydroperiod. We test this formally in Chapter 8.

6.3 Facets

plt = AlgebraOfGraphics.data(ponds_c) *
      mapping(:log_area => "log(area)",
              :richness => "Richness",
              color = :region,
              layout = :hydro) *
      (visual(Scatter, alpha = 0.6) + AlgebraOfGraphics.linear())

draw(plt; figure = (size = (800, 400),))
Figure 6.3: Richness–area relationship faceted by hydroperiod.
NoteYour turn 6.3 — Facets in the ggplot grammar

Translate Figure 6.3 to TidierPlots.jl, using facet_wrap to split the panels by hydroperiod. Leave out the fitted lines (see the warning in the solution).

There is no checker for this one: compare your figure with the one in the solution.

facet_wrap(facets = :hydro) is added with +, like any other layer.

ggplot(ponds_c, @aes(x = log_area, y = richness, color = region)) +
    geom_point(alpha = 0.6) +
    facet_wrap(facets = :hydro) +
    labs(x = "log(area)", y = "Richness")
WarningA limit of TidierPlots

In TidierPlots.jl 1.0.0 (checked in September 2026), adding geom_smooth(method = "lm") to a faceted plot fails with column name :facet not found. geom_smooth works without facets, and facets work without geom_smooth, but not together. When the ggplot grammar reaches a limit like this, AlgebraOfGraphics (Figure 6.3) is the way out.

6.4 Distributions

plt = AlgebraOfGraphics.data(ponds_c) *
      mapping(:region => "Region",
              :richness => "Richness",
              color = :hydro => "Hydroperiod",
              dodge = :hydro) *
      visual(BoxPlot)

draw(plt; figure = (size = (700, 420),))
Figure 6.4: Richness distribution by region and hydroperiod.
NoteYour turn 6.4 — The shape of a distribution

Boxplots hide the shape of a distribution. Show the distribution of body_size in each region as violins (Violin), with one colour per region.

There is no checker for this one: compare your figure with the one in the solution.

Replace BoxPlot with Violin and map region to both the x axis and color.

plt = AlgebraOfGraphics.data(ponds_c) *
      mapping(:region => "Region",
              :body_size => "Body size (mm)",
              color = :region) *
      visual(Violin)

draw(plt; figure = (size = (700, 420),))

6.5 Plain Makie.jl: full control

When a figure needs a layout the grammar cannot express, drop down to Makie.

fig = Figure(size = (860, 380))

ax1 = Axis(fig[1, 1], xlabel = "Canopy cover (%)", ylabel = "Richness",
           title = "(a) Canopy")
scatter!(ax1, ponds_c.canopy, ponds_c.richness, color = (:steelblue, 0.6))

ax2 = Axis(fig[1, 2], xlabel = "log(area)", ylabel = "Richness",
           title = "(b) Area")
scatter!(ax2, ponds_c.log_area, ponds_c.richness, color = (:darkorange, 0.6))

ax3 = Axis(fig[1, 3], xlabel = "Richness", ylabel = "Frequency",
           title = "(c) Distribution")
hist!(ax3, ponds_c.richness, bins = 12, color = (:seagreen, 0.7))

fig
Figure 6.5: A composite figure assembled by hand with Makie.

Makie’s layout system is grid-based (fig[row, col]), covering the role of patchwork and cowplot in R without an extra package.

NoteYour turn 6.5 — Two panels by hand

Build a figure with size = (800, 350) and two panels: on the left, a boxplot of richness by region; on the right, a bar chart of the proportion of ponds with the focal species in each region (the numbers from 5.4). Store it in fig_regions.

There is no checker for this one: compare your figure with the one in the solution.

Makie’s boxplot! and barplot! want numbers on the x axis. Give each region a number with [findfirst(==(r), regs) for r in ponds.region], where regs = ["Amazon", "Atlantic", "Cerrado"], and put the names back with xticks = (1:3, regs) in each Axis.

regs = ["Amazon", "Atlantic", "Cerrado"]
idx = [findfirst(==(r), regs) for r in ponds.region]
props = [mean(ponds.focal[ponds.region .== r]) for r in regs]

fig_regions = Figure(size = (800, 350))

ax1 = Axis(fig_regions[1, 1], xticks = (1:3, regs),
           xlabel = "Region", ylabel = "Richness", title = "(a) Richness")
boxplot!(ax1, idx, ponds.richness, color = (:steelblue, 0.6))

ax2 = Axis(fig_regions[1, 2], xticks = (1:3, regs),
           xlabel = "Region", ylabel = "Proportion of ponds", title = "(b) Focal species")
barplot!(ax2, 1:3, props, color = (:darkorange, 0.8))

fig_regions

The two panels tell different stories: richness differs sharply between regions, focal-species occurrence hardly at all.

6.6 Saving figures

save("figure.png", fig; px_per_unit = 3)   # high-resolution raster
save("figure.pdf", fig)                    # vector, for publication
save("figure.svg", fig)
NoteYour turn 6.6 — A figure for publication

Save fig_regions from 6.5 as focal-by-region.png, at three pixels per unit, in the book folder. Then check the file: check("6.6", "focal-by-region.png").

The block above shows the keyword that sets the resolution.

save("focal-by-region.png", fig_regions; px_per_unit = 3)
check("6.6", "focal-by-region.png")

An 800 × 350 figure at three pixels per unit gives a 2400 × 1050 px image — enough for a two-column journal figure. For vector output, save as .pdf instead.

TipChoosing a backend

CairoMakie produces publication-quality static output (PNG, PDF, SVG) and is what this book uses. GLMakie opens GPU-accelerated interactive windows — useful for exploring data, unsuitable for rendering a book.

Challenges

  1. In 6.1 you judged the temperature effect by eye. Add a linear fit per hydroperiod with geom_smooth(method = "lm"). Does the line change your reading of the plot? What would you need to know before trusting it?
  2. Panel (b) of 6.5 shows proportions without uncertainty. Add an error bar of \(\pm 2\) standard errors to each bar, with \(\text{SE} = \sqrt{p(1-p)/n}\) and errorbars!. Do the regions still look different?
  3. Redo Figure 6.5 with one colour per region in every panel and a single shared legend on the right (Legend(fig[1, 4], ...)).