AEM 6850

Empirical Methods for Applied Economists

Prof. Ariel Ortiz-Bobea

5 · Basic plots II

Tuesday, September 8, 2026

Cornell University

Objectives

By the end of this session you can:

  • Lay out panels of unequal size with layout().
  • Shade a window with rect(); draw a confidence band with polygon().
  • Turn a numeric column into a colour scale with brewer.pal() and findInterval().
  • Draw your own axes with axis(), box() and mtext().
  • Draw a matrix as a heat map with image(), and points on a map with map().
  • Write png() frames in a loop and stitch them into an animation.

Building the table

Everything below runs off one data frame, built here so this page and its script stand on their own.

pm <- read.csv("data/epa_pm25_la_county_2025.csv",
               colClasses = c("Site.ID" = "character"))
names(pm)[names(pm) == "Daily.Mean.PM2.5.Concentration"] <- "pm25"
names(pm)[names(pm) == "Local.Site.Name"]                <- "site"
pm$date <- as.Date(pm$Date, format = "%m/%d/%Y")

transect <- c("Pasadena", "Compton",
              "Long Beach-Route 710 Near Road", "Lancaster - Fairgrounds")
d      <- pm[pm$site %in% transect &
             pm$POC == 1 & pm$AQS.Parameter.Code == 88101, ]
comp_y <- d[d$site == "Compton", ]
jan    <- d[d$date < as.Date("2025-02-01"), ]
comp   <- jan[jan$site == "Compton", ]
xr     <- as.Date(c("2025-01-01", "2025-01-31"))

dim(d)
#> [1] 1119   23

layout(): panels that are not the same size

mfrow gives you equal boxes. When you want one wide panel over two small ones, hand layout() a matrix of panel numbers:

matrix(c(1, 1, 2, 3), nrow = 2, byrow = TRUE)
#>      [,1] [,2]
#> [1,]    1    1
#> [2,]    2    3

The number is the order the panel is filled. Repeat a number and that panel spans those cells; use 0 to leave a cell empty.

layout(): the five things to know

  • matrix() fills down the first column unless you say otherwise. byrow = TRUE says otherwise.
  • layout() does what mfrow does, except the panels need not be the same size.
  • heights = and widths = are ratios, not units: heights = c(2, 1) makes the top row twice as tall.
  • layout.show(n) draws the empty numbered regions so you can check a layout before spending any plots on it.
  • layout(1) is the reset. par(op) does not undo a layout().

layout.show(): checking a layout

layout(matrix(c(1, 1, 2, 3), nrow = 2, byrow = TRUE), heights = c(2, 1))
layout.show(3)
layout(1)
Three empty numbered regions: one wide panel across the top labelled 1, and two equal panels beneath labelled 2 and 3, the top row twice as tall as the bottom.

A layout in use

op <- par(mar = c(3, 4, 2, 1))
layout(matrix(c(1, 1, 2, 3), nrow = 2, byrow = TRUE), heights = c(2, 1))

plot(comp$date, comp$pm25, type = "o", xlim = xr,
     xlab = "", ylab = "PM2.5 (ug/m3)", main = "Compton, January 2025")
hist(d$pm25[d$site == "Compton"], breaks = seq(-5, 60, by = 5),
     main = "Compton, all year", xlab = "")
hist(d$pm25[d$site == "Lancaster - Fairgrounds"], breaks = seq(-5, 60, by = 5),
     main = "Lancaster, all year", xlab = "")

layout(1)
par(op)

Two settings were changed, so two get put back — layout(1) first, then par(op). The two histograms share breaks = seq(-5, 60, by = 5), which spans both stations and keeps them comparable.

A layout in use

One wide panel across the top showing Compton's January as a line with circles at each day, over two smaller panels holding histograms of Compton's and Lancaster's full-year readings on identical bins. Compton's tail runs past 55; Lancaster's mass sits three bars to the left.

Shaded areas: rect()

plot(comp_y$date, comp_y$pm25, type = "l", col = "grey30",
     xlab = "", ylab = "PM2.5 (ug/m3)")
rect(xleft = as.Date("2025-01-01"), xright = as.Date("2025-01-21"),
     ybottom = -5, ytop = 80,
     col = adjustcolor("#b31b1b", alpha.f = 0.15), border = NA)

rect() takes the four edges, and adjustcolor(col, alpha.f = ) makes any colour translucent so the data still shows through. All four edge arguments are vectorised, so one call can shade many windows.

Shaded areas: rect()

Compton's 2025 series as a line with a translucent grey band covering the first three weeks of January.

Confidence bands: polygon()

set.seed(2)
x <- runif(200, 0, 10)
y <- 20 + 3*x - 1.5*x^2 + 0.11*x^3 + 5*rnorm(200)
fit  <- lm(y ~ x + I(x^2) + I(x^3))
X    <- cbind(1, x, x^2, x^3)
se   <- sqrt(diag(X %*% vcov(fit) %*% t(X)))
i    <- order(x)

plot(x, y, pch = 21, col = "grey40", bg = "grey85", xlab = "x", ylab = "y")
polygon(c(x[i], rev(x[i])),
        c(fitted(fit)[i] + 1.96*se[i], rev(fitted(fit)[i] - 1.96*se[i])),
        col = adjustcolor("#b31b1b", alpha.f = 0.25), border = NA)
lines(x[i], fitted(fit)[i], col = "#b31b1b", lwd = 2)

polygon() takes one closed ring of coordinates. The idiom is x forwards then x backwards, upper forwards then lower backwards — that traces the band’s outline in one loop. Every confidence band, event-study plot and dose-response figure you will ever draw is this shape.

Confidence bands: polygon()

A scatter of simulated points with a red fitted cubic curve through them and a translucent red band around the curve widening at both ends.

Exercise 1

# Three minutes. "A layout in use" put the January line on top of the two
# histograms. Turn it on its side.
#
# 1. Write the matrix() call that stacks the two histograms on the LEFT and
#    gives the January line one tall panel on the RIGHT. The line is still
#    drawn first.
# 2. Check it with layout.show(3) before you draw anything.
# 3. Make the right column twice as wide as the left. Which argument?
#
# Two answers to compare with the room: your matrix, and one argument name.
layout(matrix(c(2, 3, 1, 1), nrow = 2), widths = c(1, 2))
layout.show(3)
layout(1)

Panel 1 is the line, drawn first; matrix() fills column 1 first. The argument is widths, a ratio like heights.

Colour scales

library(RColorBrewer)
cols <- colorRampPalette(brewer.pal(11, "Spectral"))(100)
v    <- runif(200, 0, 100)
plot(v, seq_along(v), pch = 16, col = cols[findInterval(v, seq(0, 100, length.out = 100))],
     xlab = "value", ylab = "")

brewer.pal(n, name) gives a designed palette (display.brewer.all() shows them all), colorRampPalette() interpolates it to any length, and findInterval() turns a numeric column into positions in that palette. Those three lines are how every choropleth and heat map gets its colours.

Colour scales

A strip of 200 points coloured along a red-to-blue spectral ramp from left to right.

Custom axes

op <- par(mar = c(3, 4, 1, 1))
plot(comp_y$date, comp_y$pm25, type = "l", col = "grey30",
     axes = FALSE, xlab = "", ylab = "")
firsts <- as.Date(paste0("2025-", sprintf("%02d", 1:12), "-01"))
axis(1, at = firsts, labels = month.abb)
axis(1, at = firsts + 15, tck = -0.01, lwd = 0, lwd.tick = 1, labels = FALSE)
axis(2, las = 2)
box()
mtext("PM2.5 (ug/m3)", side = 2, line = 2.5)
par(op)

axes = FALSE suppresses both axes so you can draw your own. A second axis() call on the same side with labels = FALSE and a small tck gives minor ticks. box() puts the frame back.

Custom axes

Compton's 2025 series with no default axes: month abbreviations along the bottom, minor ticks between them, horizontal y numbers, a box around the plot, and a margin label reading PM2.5.

par(xpd): drawing outside the plot region

lanc <- jan[jan$site == "Lancaster - Fairgrounds", ]
op   <- par(mfrow = c(1, 2), mar = c(5, 4, 3, 1))

for (clip in c(FALSE, NA)) {
  plot(comp$date, comp$pm25, type = "l", lwd = 2, col = "#b31b1b", xlim = xr,
       ylim = c(0, 60), xlab = "", ylab = "PM2.5 (ug/m3)",
       main = paste("xpd =", clip))
  lines(lanc$date, lanc$pm25, lwd = 2, col = "grey50")
  legend(xr[1], -18, c("Compton", "Lancaster"), col = c("#b31b1b", "grey50"),
         lwd = 2, bty = "n", horiz = TRUE, xpd = clip)
}

par(op)

Clipped away silently — the left legend() ran and produced nothing.

  • FALSE the plot region, TRUE the panel’s own margins, NA the device.
  • par(xpd = NA) sets it for everything after.

par(xpd): drawing outside the plot region

Two panels of the same January figure with Compton in red and Lancaster in grey. The left panel, xpd = FALSE, has no legend at all. The right panel, xpd = NA, carries the same legend on one line below the date axis.

Fills, shapes, and options

op <- par(mfrow = c(2, 2), mar = c(3, 3, 2, 1))
hist(d$pm25, breaks = 30, col = "#b31b1b", density = 25, angle = 45,
     border = "#b31b1b", main = "density = / angle =", xlab = "")

m <- table(format(d$date, "%m"), d$site)[1:4, ]
barplot(m, col = grey.colors(4), main = "stacked", las = 2, cex.names = 0.5)
barplot(m, col = grey.colors(4), beside = TRUE, space = c(0, 2),
        main = "beside = TRUE", las = 2, cex.names = 0.5)

boxplot(pm25 ~ site, data = d, notch = TRUE, boxwex = 0.5,
        main = "notch = / boxwex =", xlab = "", ylab = "", names = rep("", 4))
par(op)

A matrix handed to barplot() stacks by default and sits side by side with beside = TRUE. notch = TRUE cuts a wedge around the median whose width is a rough confidence interval: non-overlapping notches are a hint the medians differ. hist() also returns what it drew — h <- hist(x, plot = FALSE) gives you h$breaks and h$counts as ordinary vectors.

Fills, shapes, and options

Four panels: a hatched histogram, a stacked bar chart, a side-by-side bar chart, and notched box plots.

Heat maps: image()

mm <- tapply(d$pm25, list(d$site, format(d$date, "%m")), mean)
op <- par(mar = c(3, 12, 2, 1))
image(t(mm), col = colorRampPalette(c("white", "#b31b1b"))(20), axes = FALSE)
axis(1, at = seq(0, 1, length.out = 12), labels = month.abb, tick = FALSE)
mtext(rownames(mm), side = 2, at = seq(0, 1, length.out = 4), las = 2,
      cex = 0.7, line = 0.5)
box()
par(op)

image() draws a matrix as a grid of coloured cells. Note the t(): image() puts the first matrix dimension on the x axis, so a rows-are-sites matrix has to be transposed to get sites down the side.

Heat maps: image()

A four-row heat map, one row per station and one column per month, with Lancaster's row consistently pale and the basin rows darker in January and December.

Exercise 2

# Four minutes. Compton's year as points, coloured by how bad the day was.
#
# 1. Take five colours from the "Reds" Brewer palette.
# 2. Put every reading in comp_y into one of five bands, with cut points at
#    0, 10, 20, 35 and 55, and plot pm25 against date, one colour per band.
# 3. How many days are in the top band, and which colour did they get?
#
# Two answers to compare with the room: a count, and a hex code.
cols <- brewer.pal(5, "Reds")
band <- findInterval(comp_y$pm25, c(0, 10, 20, 35, 55))
plot(comp_y$date, comp_y$pm25, pch = 16, col = cols[band],
     xlab = "", ylab = "PM2.5 (ug/m3)")
sum(band == 5)
#> [1] 2

Two days (December 13 and 17), in cols[5] = #A50F15.

Maps

library(maps)
library(mapproj)
op <- par(mfrow = c(1, 2), mar = c(0, 0, 2, 0))
map("state"); title("map(\"state\")")
map("world", proj = "orthographic", orientation = c(15, 260, 0))
map("state", proj = "orthographic", orientation = c(15, 260, 0), add = TRUE)
title("orthographic")
par(op)

maps ships outlines for "world", "usa", "state" and "county". fill = TRUE with a vector of colours in the same order as the map’s own region names gives you a choropleth; add = TRUE layers one map onto another, exactly like lines() onto a plot.

The orthographic call warns projection failed for some data. That is the projection saying it cannot draw the half of the globe facing away from you, which is the correct answer. Nothing is broken.

Maps

Two panels: an outline map of the lower 48 US states, and an orthographic globe centred on North America with state outlines drawn on it.

Animation

library(magick)
frames <- file.path(tempdir(), "frames")
dir.create(frames, showWarnings = FALSE)
for (angle in seq(0, 355, by = 5)) {
  png(file.path(frames, sprintf("frame_%03d.png", angle)), width = 480, height = 480)
  par(mar = c(0, 0, 0, 0))
  map("world", proj = "orthographic", orientation = c(15, angle, 0))
  dev.off()
}
globe <- image_animate(image_read(list.files(frames, full.names = TRUE)), fps = 10)
globe

A GIF is a folder of PNGs shown in order. Everything in that loop is png() and dev.off() plus a counter; image_read() loads the frames and image_animate() strings them together. image_write(globe, "globe.gif") saves it. Any figure you can draw once you can animate.

Animation

An orthographic globe with country outlines, rotating one full turn.

Practice

Fourteen exercises, with solutions, on the page and in the script.

  • 1–3 Unequal panels: read a layout() matrix, fill it, set heights.
  • 4–6 Shaded areas: a window, five bands in one call, overlaid histograms.
  • 7 A fitted curve with its 95% band from polygon().
  • 8–9 Colour scales: a 12-colour Brewer ramp; findInterval() buckets.
  • 10 Custom axes: month names, mid-month ticks, a label with mtext().
  • 11–12 Stacked versus beside bars; notched box plots.
  • 13–14 A site-by-month heat map; four monitors on a map of California.

Quick reference

On the page: every command on this page in one table.