Deep dive: layers

Notes
Modified

August 26, 2026

NoteLearning objectives
  • Identify common geom types for single and multiple variable charts
  • Generate charts using the same variables and different geoms
  • Create a lollipop chart using {ggplot2}
  • Utilize position adjustments
  • Evaluate the effectiveness of geom type choice for specific combinations of variables

Data: Sale prices of houses in Tompkins County

Throughout this lesson we’ll use data on houses sold in Tompkins County, NY from 2022–24, scraped from Redfin.

tompkins <- read_csv("data/tompkins-home-sales.csv")
glimpse(tompkins)
Rows: 1270 Columns: 12
── Column specification ──────────────────────────────────────────────────────────────────
Delimiter: ","
chr  (2): town, municipality
dbl  (9): price, beds, baths, area, lot_size, year_built, hoa_month, long, lat
date (1): sold_date

β„Ή Use `spec()` to retrieve the full column specification for this data.
β„Ή Specify the column types or set `show_col_types = FALSE` to quiet this message.
Rows: 1,270
Columns: 12
$ sold_date    <date> 2022-09-12, 2022-09-12, 2022-09-12, 2022-09-13, 2022-07-22, 2022-0…
$ price        <dbl> 340000, 390000, 625500, 246600, 172000, 205000, 230000, 246000, 350…
$ beds         <dbl> 2, 4, 2, 2, NA, 2, 5, 5, 3, 5, 3, 2, 2, 4, 3, 5, 4, 3, 4, 3, 3, 3, …
$ baths        <dbl> 3.0, 3.0, 3.0, 1.5, NA, 1.0, 2.0, 2.0, 2.5, 4.0, 1.0, 1.5, 2.0, 2.5…
$ area         <dbl> 1864, 3252, 1704, 1264, 2644, 820, 2900, 2364, 2016, 2882, 1246, 11…
$ lot_size     <dbl> 4.50000000, 0.33999082, 65.00000000, 0.21000918, 0.13000459, 0.2399…
$ year_built   <dbl> 1999, 1988, 1988, 1953, 1870, 1932, 1850, 1985, 1984, 2002, 1961, 2…
$ hoa_month    <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
$ town         <chr> "Newfield", "Ithaca", "Dryden", "Ithaca", "Dryden", "Ithaca", "Lans…
$ municipality <chr> "Unincorporated", "Unincorporated", "Unincorporated", "Ithaca city"…
$ long         <dbl> -76.59488, -76.45546, -76.35953, -76.52435, -76.29872, -76.48761, -…
$ lat          <dbl> 42.38609, 42.47046, 42.43971, 42.45208, 42.49046, 42.42739, 42.6182…

A basic scatterplot with a linear trend line gives us an overview of the relationship between house area and sale price:

ggplot(data = tompkins, mapping = aes(x = area, y = price)) +
  geom_point(alpha = 0.7, size = 2) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.7) +
  labs(
    x = "Area (square feet)",
    y = "Sale price (USD)",
    title = "Price and area of houses in Tompkins County"
  )
`geom_smooth()` using formula = 'y ~ x'

We can add more structure to this by grouping houses by the decade they were built. The %/% operator performs integer division β€” dividing by 10 and multiplying back gives us the decade floor for any year:

tompkins <- tompkins |>
  mutate(decade_built = (year_built %/% 10) * 10)

tompkins |>
  select(year_built, decade_built)
1
%/% is integer (floor) division. 1987 %/% 10 gives 198; multiplying by 10 gives 1980.
# A tibble: 1,270 Γ— 2
   year_built decade_built
        <dbl>        <dbl>
 1       1999         1990
 2       1988         1980
 3       1988         1980
 4       1953         1950
 5       1870         1870
 6       1932         1930
 7       1850         1850
 8       1985         1980
 9       1984         1980
10       2002         2000
# β„Ή 1,260 more rows

To keep the chart readable, we collapse the earliest and most recent decades into aggregate categories using case_when():

tompkins <- tompkins |>
  mutate(
    decade_built_cat = case_when(
      decade_built <= 1940 ~ "1940 or before",
      decade_built >= 1990 ~ "1990 or after",
      .default = as.character(decade_built)
    )
  )

tompkins |>
  count(decade_built_cat)
1
Edge decades are collapsed into β€œbefore” and β€œafter” buckets to avoid tiny categories.
2
.default handles all remaining decades, converting the numeric decade to a string label.
# A tibble: 6 Γ— 2
  decade_built_cat     n
  <chr>            <int>
1 1940 or before     443
2 1950               117
3 1960               120
4 1970               136
5 1980               143
6 1990 or after      311

With this variable we can facet the scatterplot by decade:

ggplot(
  data = tompkins,
  mapping = aes(x = area, y = price, color = decade_built_cat)
) +
  geom_point(alpha = 0.7, show.legend = FALSE) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 0.5, show.legend = FALSE) +
  scale_x_continuous(labels = label_number(scale_cut = cut_short_scale())) +
  scale_y_continuous(labels = label_currency(scale_cut = cut_short_scale())) +
  facet_wrap(facets = vars(decade_built_cat)) +
  labs(
    x = "Area (square feet)",
    y = "Sale price (USD)",
    title = "Price and area of houses in Tompkins County"
  )
`geom_smooth()` using formula = 'y ~ x'

Geoms

A geom is the geometric object used to represent observations in a plot. Choosing the right geom is one of the most consequential visualization decisions you’ll make β€” the same data can tell very different stories depending on how it is drawn.

{ggplot2} provides a geom_*() function for every major chart type. The right choice depends primarily on two things: how many variables you’re showing and what types they are (continuous vs. discrete).

One variable

Discrete variables

For a single discrete (categorical) variable, geom_bar() is the default: it counts how many observations fall in each category and renders a bar for each.

Continuous variables

For a single continuous variable, you have several options depending on what you want to emphasize:

  • geom_histogram() β€” bin and count into bars; good for seeing the shape of the distribution
  • geom_density() β€” smoothed kernel density estimate; good for comparing shapes across groups
  • geom_freqpoly() β€” like a histogram but drawn as a line; makes multi-group comparisons easier
  • geom_dotplot() β€” stack individual points; good for small datasets

When comparing distributions across groups, the choice between these matters. A stacked or overlapping histogram can be hard to read:

ggplot(tompkins, aes(x = price, fill = decade_built_cat)) +
  geom_histogram(binwidth = 100000)
1
Stacked histograms make it hard to compare the heights of bars across groups β€” only the bottom group has a stable baseline at zero.

ggplot(tompkins, aes(x = price, color = decade_built_cat)) +
  geom_freqpoly(binwidth = 100000, linewidth = 1)
1
geom_freqpoly() draws the same information as a histogram but as lines, making multi-group comparison straightforward since lines don’t occlude each other.

The frequency polygon is easier to compare across groups because all lines share a common baseline and don’t overlap in the way bars do.

Two variables

Both continuous

For two continuous variables the standard options are:

  • geom_point() β€” scatterplot; the go-to for exploring relationships
  • geom_smooth() β€” fitted curve with optional confidence band
  • geom_quantile() β€” quantile regression lines
  • geom_rug() β€” marginal tick marks along each axis showing the marginal distribution
  • geom_text() or geom_label() β€” text labels at each (x, y) position

Showing density for large datasets

When there are many observations, individual points overlap and a scatterplot becomes an unreadable mass of ink. Density geoms bin the 2D space and count points within each bin:

  • geom_bin2d() β€” rectangular bins
  • geom_hex() β€” hexagonal bins (less visual artifact from rectangles)
  • geom_density2d() β€” contour lines of a smoothed 2D density

geom_hex() illustrates how the appropriate geom scales with dataset size:

tompkins |>
  filter(decade_built == 1940) |>
  ggplot(aes(x = area, y = price)) +
  geom_hex()
1
With only 38 observations, most hex cells contain just one point. The binning adds no value β€” geom_point() would be clearer.

ggplot(tompkins, aes(x = area, y = price)) +
  geom_hex()
1
With 1270 observations there is some overplotting, and geom_hex() starts to reveal density patterns.

ggplot(diamonds, aes(x = carat, y = price)) +
  geom_hex()
1
With 53940 observations, hexagonal binning is clearly the right choice β€” a scatterplot would be an unreadable blob.

The fill scale in geom_hex() defaults to a linear count. For highly skewed data, a log-transformed fill scale reveals structure across orders of magnitude:

ggplot(diamonds, aes(x = carat, y = price)) +
  geom_hex() +
  scale_fill_gradient(transform = "log10")
1
transform = "log10" applies a log scale to the fill color mapping. This prevents the one or two highest-density cells from dominating the color scale and washing out all other variation.

One continuous, one discrete

When one variable is discrete, you’re usually comparing a distribution or summary across groups:

  • geom_boxplot() β€” five-number summary per group
  • geom_violin() β€” full density estimate per group
  • geom_col() β€” bar chart of pre-computed summary values
  • geom_count() β€” scale point size by count at each location

Handling overplotting with geom_jitter()

When one variable is discrete and the other is continuous, geom_point() stacks all points for a given category into a vertical line β€” overplotting obscures how many points are there. geom_jitter() adds a small random horizontal nudge to each point to reveal the density:

ggplot(tompkins, aes(x = beds, y = price)) +
  geom_point()
1
Points are stacked directly on top of each other at each integer bed count, making it impossible to see how many observations are in each column.

ggplot(tompkins, aes(x = beds, y = price)) +
  geom_jitter()
1
Each point is nudged by a small random amount. The spread is random, so the result looks slightly different every time the code runs.

That randomness matters when results need to be reproducible. set.seed() pins the random number generator so the same jitter positions are produced on every render:

ggplot(tompkins, aes(x = beds, y = price)) +
  geom_jitter() # point positions change every render
Warning: Removed 42 rows containing missing values or values outside the scale range
(`geom_point()`).

set.seed(531)
ggplot(tompkins, aes(x = beds, y = price)) +
  geom_jitter()
1
set.seed(531) before the plot ensures the same random positions are generated each time. Use any integer β€” what matters is consistency.

One continuous, one time variable

For time series data you have several geom choices:

  • geom_line() β€” connect points in time order; the standard for time series
  • geom_area() β€” like geom_line() but fills the area below the line to zero
  • geom_step() β€” staircase-style; good for data that changes discretely rather than continuously

The four geoms behave quite differently on the same mean price by decade data:

ggplot(mean_price_year, aes(x = decade_built, y = mean_price)) +
  geom_point()
1
geom_point() shows each decade’s mean without implying continuity between them. Appropriate if you want to treat decades as unordered categories rather than a time axis.

ggplot(mean_price_year, aes(x = decade_built, y = mean_price)) +
  geom_line()
1
geom_line() connects the dots in order of x, implying a continuous trend over time. The standard choice for time series.

ggplot(mean_price_year, aes(x = decade_built, y = mean_price)) +
  geom_area()
1
geom_area() fills the region under the line to zero. This emphasizes the absolute magnitude at each point rather than just the shape of the trend β€” useful when β€œhow much total” matters, but it can be misleading when the baseline (zero) is far from the data range.

ggplot(mean_price_year, aes(x = decade_built, y = mean_price)) +
  geom_step()
1
geom_step() draws horizontal segments that jump at each x value. It explicitly conveys that the value is constant within each period and only changes at the boundary β€” well-suited for data that is genuinely step-wise (e.g., interest rate history, bin counts).

Displaying uncertainty

When showing summary statistics, it is good practice to convey the uncertainty around each estimate:

  • geom_errorbar() β€” vertical bars spanning a confidence or standard deviation interval
  • geom_linerange() β€” like an error bar without the end caps
  • geom_pointrange() β€” a point at the center with a line spanning the interval
  • geom_crossbar() β€” a box (like a boxplot middle) with a center line

Putting it together

Here is the full scatterplot from earlier, polished with proper axis labels and a color choice:

ggplot(tompkins, aes(x = area, y = price)) +
  geom_point(alpha = 0.2, size = 2, color = "#B31B1B") +
  scale_x_continuous(labels = label_comma()) +
  scale_y_continuous(
    labels = label_currency(scale_cut = cut_short_scale())
  ) +
  labs(
    x = "Area (square feet)",
    y = "Sale price (USD)",
    title = "Sale prices of homes in Tompkins County, NY",
    subtitle = "2022–24",
    caption = "Source: Redfin.com"
  )
1
label_comma() formats the x-axis numbers with commas (e.g., 2,000 instead of 2000).
2
label_currency(scale_cut = cut_short_scale()) formats y-axis labels as currency with SI suffixes (e.g., $300K instead of $300,000).

Constructing a lollipop chart

Sometimes the chart we want to construct does not have a direct implementation in {ggplot2}. However, we can often combine existing geoms to achieve the desired effect. For example, we might want to visualize the mean sales price of houses in Tompkins County by decade built.

A bar chart is a common choice, but it can be visually heavy and distract from the data. Alternatively, we might use a dot plot, which is lighter and emphasizes the data points themselves rather than the bars.

The dot plot above is better than the bar chart for a dataset with a few categories. But a dot floating in space gives no visual anchor to the axis β€” it can be harder to trace the value back to zero or to compare distances across categories.

A lollipop chart splits the difference: it keeps the minimal ink of a dot but adds a thin line connecting each point back to the axis.

πŸ“ Make a lollipop chart

There is no geom_lollipop() in {ggplot2}, but we can construct it using a combination of layers.

Your turn: Define the conceptual grammar of graphics for a lollipop chart to visualize the average sales price by decade built. Focus specifically on the layer(s) needed to create the β€œlollipop” effect, the geometric object(s), and the mapping aesthetics required.

TipChoosing appropriate geom(s)

Try to construct the chart without using geom_col(). You would have to spend more time tweaking some of the function’s parameters so it looks appropriate.

There is another geom_*() that works pretty well here.

The grammar of graphics for a lollipop chart includes:

  • Layer 1 (the β€œstick”)
    • Data: mean_price_decade
    • Geometric object: geom_segment()
    • Mapping aesthetics:
      • x: 0
      • xend: mean_price
      • y: decade_built_cat
      • yend: decade_built_cat
  • Layer 2 (the β€œcandy”)
    • Data: mean_price_decade
    • Geometric object: geom_point()
    • Mapping aesthetics:
      • x: mean_price
      • y: decade_built_cat

Your turn: Now implement your lollipop chart using ggplot().

#| setup: true
#| exercise: lollipop-chart

library(tidyverse)
theme_set(theme_minimal())
options(scipen = 999)

tompkins <- read_csv("data/tompkins-home-sales.csv") |>
  mutate(
    decade_built = (year_built %/% 10) * 10,
    decade_built_cat = case_when(
      decade_built <= 1940 ~ "1940 or before",
      decade_built >= 1990 ~ "1990 or after",
      .default = as.character(decade_built)
    )
  )

mean_price_decade <- tompkins |>
  group_by(decade_built_cat) |>
  summarize(mean_price = mean(price))
#| exercise: lollipop-chart
#| caption: Lollipop chart
# add code here
ggplot(data = mean_price_decade, mapping = aes(x = ______, y = ______)) +
  geom______() +
  geom______()
NoteHint

Use two layers: geom_segment() for the stick (with x = 0 and xend = mean_price) and geom_point() for the candy.

Solution.

TipSuggested solution
ggplot(
  data = mean_price_decade,
  mapping = aes(x = mean_price, y = decade_built_cat)
) +
  geom_point(size = 4) +
  geom_segment(
    mapping = aes(
      x = 0,
      xend = mean_price,
      y = decade_built_cat,
      yend = decade_built_cat
    )
  ) +
  labs(
    x = "Mean sales price",
    y = "Decade built",
    title = "Mean sales price of houses in Tompkins County, by decade built"
  )

You can also reuse the global y aesthetic in geom_segment() to reduce repetition:

ggplot(
  data = mean_price_decade,
  mapping = aes(x = mean_price, y = decade_built_cat)
) +
  geom_point(size = 4) +
  geom_segment(
    mapping = aes(
      xend = 0,
      yend = decade_built_cat
    )
  ) +
  labs(
    x = "Mean sales price",
    y = "Decade built",
    title = "Mean sales price of houses in Tompkins County, by decade built"
  )

This reduces the data-ink ratio compared to the bar chart, while still communicating the same information.

Global vs. layer-specific aesthetics

{ggplot2} lets you specify aesthetic mappings in three places: in the initial ggplot() call (global), in individual geom_*() calls (layer-specific), or both. When you have multiple layers, the distinction matters.

The lollipop above specifies y = decade_built_cat and x = mean_price globally, and then geom_segment() adds its own x = 0 and xend = mean_price on top. Here is an equivalent version where geom_segment() inherits less from the global mapping:

ggplot(
  data = mean_price_decade,
  mapping = aes(y = decade_built_cat, x = mean_price)
) +
  geom_point(size = 4) +
  geom_segment(
    mapping = aes(
      xend = 0,
      yend = decade_built_cat
    )
  ) +
  scale_x_continuous(labels = label_currency(scale_cut = cut_short_scale())) +
  labs(
    x = "Mean sales price",
    y = "Decade built",
    title = "Mean sales price of houses in Tompkins County"
  ) +
  theme(plot.title.position = "plot")
1
x = mean_price and y = decade_built_cat are inherited by all layers.
2
In geom_segment(), x is inherited (the starting point), and only xend and yend are specified explicitly. The result is identical β€” but this version relies more heavily on aesthetic inheritance.

The key rule: each layer inherits the global aesthetics and can add, override, or remove them. If you only have one layer, it doesn’t matter where you put the aesthetic mapping. Once you add a second layer, you need to be deliberate.

To make the distinction concrete, examine these three plots:

# Plot A β€” color is mapped to a variable
ggplot(data = tompkins, mapping = aes(x = area, y = price)) +
  geom_point(mapping = aes(color = decade_built_cat))
1
color = decade_built_cat is inside aes() β€” it maps the variable to color, producing a legend.

# Plot B β€” color is set to a named color string
ggplot(data = tompkins, mapping = aes(x = area, y = price)) +
  geom_point(color = "blue")
1
color = "blue" is outside aes() β€” it sets every point to blue. No legend is produced.

# Plot C β€” color is set to a hex code
ggplot(data = tompkins, mapping = aes(x = area, y = price)) +
  geom_point(color = "#A493BA")
1
color = "#A493BA" works the same as Plot B β€” a fixed color applied uniformly. Named colors and hex codes are interchangeable.

Summary

  • {ggplot2} charts are built from layers β€” geom_point(), geom_segment(), and other geoms can be stacked on the same coordinate system
  • {ggplot2} provides geom_*() functions for every major chart type; the right choice depends on the number and types of variables
  • Use set.seed() before geom_jitter() (or any geom using randomness) to ensure reproducible output
  • Aesthetic mappings can be specified globally in ggplot() or locally in individual geoms; layers inherit global mappings and can override them

Acknowledgements

Material derived in part from STA 313: Advanced Data Visualization.