tompkins <- read_csv("data/tompkins-home-sales.csv")
glimpse(tompkins)Deep dive: layers
- 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.
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 %/% 10gives198; multiplying by 10 gives1980.
# 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
-
.defaulthandles 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 distributiongeom_density()β smoothed kernel density estimate; good for comparing shapes across groupsgeom_freqpoly()β like a histogram but drawn as a line; makes multi-group comparisons easiergeom_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:
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 relationshipsgeom_smooth()β fitted curve with optional confidence bandgeom_quantile()β quantile regression linesgeom_rug()β marginal tick marks along each axis showing the marginal distributiongeom_text()orgeom_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 binsgeom_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:
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 groupgeom_violin()β full density estimate per groupgeom_col()β bar chart of pre-computed summary valuesgeom_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:
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:
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 seriesgeom_area()β likegeom_line()but fills the area below the line to zerogeom_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 ofx, 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 intervalgeom_linerange()β like an error bar without the end capsgeom_pointrange()β a point at the center with a line spanning the intervalgeom_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.
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: 0xend:mean_pricey:decade_built_catyend:decade_built_cat
- Data:
- Layer 2 (the βcandyβ)
- Data:
mean_price_decade - Geometric object:
geom_point() - Mapping aesthetics:
x:mean_pricey:decade_built_cat
- Data:
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______()
Use two layers: geom_segment() for the stick (with x = 0 and xend = mean_price) and geom_point() for the candy.
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_priceandy = decade_built_catare inherited by all layers. - 2
-
In
geom_segment(),xis inherited (the starting point), and onlyxendandyendare 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_catis insideaes()β 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 outsideaes()β it sets every point to blue. No legend is produced.
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()beforegeom_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.























