Cookbook
Every exported function and every option that changes the output, as worked examples. Each figure below was rendered by ggnext when this page was built, and several sections check the computed numbers against base R.
This page is generated from inst/examples/ggnext-full-reference.Rmd, which ships with the package. Open it in RStudio to run any of it yourself: system.file("examples", "ggnext-full-reference.Rmd", package = "ggnext")
- 1. The grammar
- 2. Essential geoms
- 3. Distributions
- 4. Scales and axes
- 5. Coordinates
- 6. Facets
- 7. Titles, labels and themes
- 8. Layout geoms
- 9. Machine-learning geoms
- 10. Clinical geoms
- 11. Interactivity and animation
- 12. Exact data export
- 13. Rendering and the logo
- 14. Extending the package
- 15. Error handling
- 16. Edge cases
- 17. Session information
- 1. The grammar
- 2. Essential geoms
- 3. Distributions
- 4. Scales and axes
- 5. Coordinates
- 6. Facets
- 7. Titles, labels and themes
- 8. Layout geoms
- 9. Machine-learning geoms
- 10. Clinical geoms
- 11. Interactivity and animation
- 12. Exact data export
- 13. Rendering and the logo
- 14. Extending the package
- 15. Error handling
- 16. Edge cases
- 17. Session information
This document exercises every exported function of ggnext and every option that changes what is drawn. It doubles as a regression check: if any layer, scale, coordinate system or theme setting breaks, this document fails to knit.
Writing a plot object in a chunk renders it inline — ggnext registers a
knit_print() method, so no results='asis' boilerplate is needed.
packageVersion("ggnext")
#> [1] '0.1.0'
length(getNamespaceExports("ggnext"))
#> [1] 154
1. The grammar
1.1 Minimal plot
A plot is data, an aesthetic mapping, and at least one layer.
ggnext(cars, aes(speed, dist)) + geom_point()
1.2 Plots are immutable values
+ returns a new plot, so a partial specification is a reusable template.
base <- ggnext(iris, aes(Sepal.Length, Sepal.Width))
length(base@layers) # still empty
#> [1] 0
length((base + geom_point())@layers)
#> [1] 1
length(base@layers) # base is unchanged
#> [1] 0
1.3 aes() — mapping vs setting
aes(speed, dist, color = gear)
#> <ggnext aesthetic mapping>
#> x -> speed
#> y -> dist
#> color -> gear
A constant inside aes() that names a real colour is honoured literally
rather than being treated as a one-level category.
ggnext(cars, aes(speed, dist, color = "steelblue")) + geom_point(size = 4)
1.4 Layer-level data and mapping
A layer can override both, which is how you annotate one plot with a second dataset.
means <- aggregate(Sepal.Length ~ Species, iris, mean)
ggnext(iris, aes(Species, Sepal.Length)) +
geom_jitter(alpha = 0.3) +
geom_point(aes(Species, Sepal.Length), data = means,
color = "#C1462F", size = 7)
1.5 Output size
p_wide <- ggnext(cars, aes(speed, dist), width = 900, height = 260) +
geom_point()
p_wide
plot_size() does the same thing after the fact.
b <- ggnext:::build_geometry(ggnext(cars, aes(speed, dist)) +
geom_point() + plot_size(800, 600))
c(width = b$width, height = b$height)
#> width height
#> 800 600
2. Essential geoms
2.1 geom_point
ggnext(cars, aes(speed, dist)) + geom_point(color = "#2B6BE0", size = 4,
alpha = 0.7)
Size and colour can be mapped instead of set:
ggnext(iris, aes(Sepal.Length, Sepal.Width,
color = Species, size = Petal.Length)) +
geom_point(alpha = 0.75)
2.2 geom_jitter
ggnext(iris, aes(Species, Sepal.Width, color = Species)) +
geom_jitter(alpha = 0.7) +
theme(legend_position = "none")
Jitter is seeded, so the same plot renders identically every time — and it restores the caller’s random stream:
set.seed(42); before <- .Random.seed
invisible(render(ggnext(iris, aes(Species, Sepal.Width)) + geom_jitter()))
identical(.Random.seed, before)
#> [1] TRUE
2.3 geom_line, geom_path, geom_step
ts <- data.frame(
t = rep(1:12, 2),
v = c(cumsum(rnorm(12, 2)), cumsum(rnorm(12, 1))),
g = rep(c("A", "B"), each = 12)
)
ggnext(ts, aes(t, v, color = g)) + geom_line(linewidth = 2) + theme_minimal()
geom_path() follows data order rather than x order; geom_step() holds
each value until the next observation.
steps <- data.frame(t = 1:8, v = c(2, 2, 3, 3, 5, 4, 4, 6))
ggnext(steps, aes(t, v)) + geom_step(color = "#12A594", linewidth = 2) +
geom_point() + theme_minimal()
Dashed lines via dash (an SVG dash pattern):
ggnext(steps, aes(t, v)) + geom_line(dash = "6,4") + theme_minimal()
2.4 geom_area and geom_ribbon
ggnext(data.frame(t = 1:10, v = c(2, 4, 3, 6, 8, 7, 9, 8, 11, 13)),
aes(t, v)) +
geom_area(alpha = 0.45) + theme_minimal()
band <- data.frame(t = 1:12, lo = (1:12) * 0.8, hi = (1:12) * 1.4)
ggnext(band, aes(t, ymin = lo, ymax = hi)) +
geom_ribbon(alpha = 0.4) + theme_minimal()
2.5 geom_segment and geom_dumbbell
seg <- data.frame(x = 1:4, y = c(2, 4, 3, 5),
xe = (1:4) + 0.7, ye = c(4, 6, 2, 7))
ggnext(seg, aes(x, y, xend = xe, yend = ye)) +
geom_segment(linewidth = 2) + theme_minimal()
db <- data.frame(g = c("North", "South", "East", "West"),
before = c(12, 18, 9, 14), after = c(19, 21, 15, 13))
ggnext(db, aes(before, xend = after, y = g)) +
geom_dumbbell() + theme_minimal()
2.6 Reference lines
geom_hline() and geom_vline() span the panel and ignore the plot’s
aes(), so they never disturb the data mapping.
ggnext(cars, aes(speed, dist)) +
geom_point(alpha = 0.6) +
geom_hline(mean(cars$dist), dash = "5,4", color = "#C1462F") +
geom_vline(mean(cars$speed), dash = "5,4", color = "#C1462F") +
theme_minimal()
2.7 geom_text
lab <- data.frame(x = c(1, 2, 3), y = c(3, 1, 2),
l = c("alpha", "beta", "gamma"))
ggnext(lab, aes(x, y, label = l)) +
geom_point(size = 6, alpha = 0.25) + geom_text() + theme_minimal()
2.8 geom_tile
grid <- expand.grid(x = 1:10, y = 1:8)
grid$z <- as.vector(outer(1:10, 1:8, function(a, b) sin(a / 2) + cos(b / 2)))
ggnext(grid, aes(x, y, color = z)) + geom_tile() + theme_minimal()
3. Distributions
3.1 geom_bar and geom_col
geom_bar() counts rows; geom_col() takes the height from y.
cnt <- data.frame(g = c("a", "a", "a", "b", "b", "c"))
ggnext(cnt, aes(g)) + geom_bar() + theme_minimal()
vals <- data.frame(g = c("alpha", "beta", "gamma", "delta"),
v = c(12, 27, 19, 8))
ggnext(vals, aes(g, v, color = g)) + geom_col() +
theme(legend_position = "none")
3.2 Position adjustments
grp <- data.frame(g = rep(c("Q1", "Q2", "Q3"), each = 3),
grp = rep(c("a", "b", "c"), 3),
v = c(4, 6, 3, 7, 4, 5, 5, 8, 2))
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "stack") +
labs(title = 'position = "stack" (default)') + theme_minimal()
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "dodge") +
labs(title = 'position = "dodge"') + theme_minimal()
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "fill") +
labs(title = 'position = "fill" (proportions)') + theme_minimal()
Stacked totals train the axis correctly — the domain covers the stack, not the tallest single bar:
stacked <- ggnext:::build_geometry(
ggnext(grp, aes(g, v, color = grp)) + geom_col(position = "stack")
)
stacked$panels[[1]]$y$domain
#> [1] -0.8 16.8
3.3 geom_histogram
bins = n yields exactly n bins, and counts sum to the row count.
ggnext(cars, aes(speed)) + geom_histogram(bins = 8) + theme_minimal()
h <- plot_data(ggnext(cars, aes(speed)) + geom_histogram(bins = 5))
h
#> x y ymin ymax xmin xmax
#> 1 6.1 5 0 5 4.0 8.2
#> 2 10.3 10 0 10 8.2 12.4
#> 3 14.5 13 0 13 12.4 16.6
#> 4 18.7 15 0 15 16.6 20.8
#> 5 22.9 7 0 7 20.8 25.0
c(bins = nrow(h), total = sum(h$y), rows = nrow(cars))
#> bins total rows
#> 5 50 50
binwidth overrides bins:
ggnext(cars, aes(speed)) + geom_histogram(binwidth = 5) + theme_minimal()
3.4 geom_density
ggnext(iris, aes(Sepal.Length, color = Species)) +
geom_density(alpha = 0.45) + theme_minimal()
The estimate integrates to 1:
dd <- compute_stat(stat_density(), list(x = rnorm(500),
group = rep("all", 500)))
round(sum(dd$y) * diff(dd$x[1:2]), 3)
#> [1] 1
3.5 geom_boxplot
ggnext(iris, aes(Species, Sepal.Length, color = Species)) +
geom_boxplot() + theme(legend_position = "none")
Quartiles match stats::quantile():
bx <- plot_data(ggnext(iris, aes(Species, Sepal.Length)) + geom_boxplot())
setosa <- iris$Sepal.Length[iris$Species == "setosa"]
c(computed = bx$middle[1], base_r = median(setosa))
#> computed base_r
#> 5 5
3.6 geom_violin
ggnext(iris, aes(Species, Sepal.Width, color = Species)) +
geom_violin() + theme(legend_position = "none")
3.7 Layered distribution view
Layers draw in the order added: shape underneath, summary, raw data on top.
ggnext(iris, aes(Species, Sepal.Length, color = Species)) +
geom_violin(alpha = 0.25) +
geom_boxplot() +
geom_jitter(alpha = 0.4) +
theme(legend_position = "none")
3.8 geom_ridgeline
ggnext(iris, aes(Sepal.Length, Species)) +
geom_ridgeline() + theme_minimal()
ggnext(iris, aes(Sepal.Length, Species)) +
geom_ridgeline(scale = 3, alpha = 0.55) +
labs(title = "scale = 3 makes ridges overlap") + theme_minimal()
3.9 geom_smooth
ggnext(cars, aes(speed, dist)) +
geom_point(alpha = 0.6) + geom_smooth(method = "lm") + theme_minimal()
The linear fit matches stats::lm():
sm <- compute_stat(stat_smooth(method = "lm"),
list(x = cars$speed, y = cars$dist,
group = rep("all", nrow(cars))))
fit <- lm(dist ~ speed, cars)
max(abs(sm$y - unname(predict(fit, data.frame(speed = sm$x)))))
#> [1] 0
ggnext(cars, aes(speed, dist)) +
geom_point(alpha = 0.6) + geom_smooth(method = "loess", se = FALSE) +
theme_minimal()
3.10 Error bars and point ranges
eb <- data.frame(g = c("A", "B", "C", "D"), m = c(5, 7, 4, 8),
lo = c(4, 6.2, 3.1, 7.1), hi = c(6, 7.8, 4.9, 8.9))
ggnext(eb, aes(g, m, ymin = lo, ymax = hi)) +
geom_errorbar() + theme_minimal()
ggnext(eb, aes(g, m, ymin = lo, ymax = hi)) +
geom_pointrange() + theme_minimal()
3.11 geom_waterfall
wf <- data.frame(
step = factor(c("Start", "Sales", "Costs", "Tax", "End"),
levels = c("Start", "Sales", "Costs", "Tax", "End")),
v = c(100, 45, -30, -12, 0)
)
ggnext(wf, aes(step, v)) + geom_waterfall() + theme_minimal()
4. Scales and axes
4.1 Axis titles, breaks, labels, padding
ggnext(cars, aes(speed, dist)) +
geom_point() +
scale_x_continuous(
name = "Speed (mph)",
breaks = c(5, 10, 15, 20, 25),
expand = 0
) +
scale_y_continuous(
name = "Stopping distance",
labels = function(v) paste0(v, " ft")
) +
theme_minimal()
expand = 0 makes the domain exactly the data range:
tight <- ggnext:::build_geometry(
ggnext(cars, aes(speed, dist)) + geom_point() +
scale_x_continuous(expand = 0)
)
rbind(domain = tight$panels[[1]]$x$domain, data = range(cars$speed))
#> [,1] [,2]
#> domain 4 25
#> data 4 25
4.2 Limits
ggnext(cars, aes(speed, dist)) + geom_point() +
xlim(0, 30) + ylim(0, 150) + theme_minimal()
lims() sets both at once. Limits set the domain, not a crop — data
outside still goes through the stats and is clipped when drawn.
ggnext(cars, aes(speed, dist)) + geom_point() +
lims(x = c(10, 20)) +
geom_smooth(method = "lm") +
labs(subtitle = "the fit still uses all 50 rows") + theme_minimal()
4.3 Transforms
lg <- data.frame(x = 10^(1:5), y = 1:5)
ggnext(lg, aes(x, y)) + geom_point(size = 5) + scale_x_log10() +
theme_minimal()
Positions are the log of the data; labels stay in original units.
lb <- ggnext:::build_geometry(
ggnext(lg, aes(x, y)) + geom_point() + scale_x_log10()
)
unlist(lb$panels[[1]]$x$ticks$labels)
#> [1] "10" "100" "1000" "10000" "100000"
sq <- data.frame(x = c(1, 4, 9, 16, 25), y = 1:5)
ggnext(sq, aes(x, y)) + geom_point(size = 5) + scale_x_sqrt() +
theme_minimal()
ggnext(sq, aes(x, y)) + geom_point(size = 5) + scale_y_reverse() +
labs(title = "scale_y_reverse()") + theme_minimal()
4.4 Discrete scales
Explicit level order — how you sort bars by size rather than alphabetically.
srt <- data.frame(name = c("delta", "alpha", "gamma", "beta"),
value = c(8, 12, 19, 27))
srt <- srt[order(-srt$value), ]
ggnext(srt, aes(name, value)) + geom_col() +
scale_x_discrete(limits = srt$name) + theme_minimal()
4.5 Colour scales
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3) +
scale_color_manual(c("#2B6BE0", "#E05A2B", "#12A594")) +
theme_minimal()
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Petal.Length)) +
geom_point(size = 3) +
scale_color_gradient(low = "#FFF3B0", high = "#9E2A2B") +
labs(color = "Petal length") + theme_minimal()
5. Coordinates
5.1 coord_flip
ggnext(vals, aes(g, v, color = g)) + geom_col() + coord_flip() +
theme(legend_position = "none")
5.2 coord_polar
days <- data.frame(d = c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"),
v = c(4, 7, 6, 9, 12, 15, 11))
ggnext(days, aes(d, v, color = d)) + geom_col() + coord_polar() +
theme(legend_position = "none")
Donut hole, direction and start angle:
ggnext(days, aes(d, v, color = d)) + geom_col() +
coord_polar(inner = 0.35, direction = -1, start = pi / 4) +
theme(legend_position = "none")
Categories wrap evenly around the full turn:
pol <- ggnext:::build_geometry(
ggnext(days, aes(d, v)) + geom_col() + coord_polar()
)
unlist(pol$panels[[1]]$polar$spokes)
#> [1] 0.0000000 0.1428571 0.2857143 0.4285714 0.5714286 0.7142857 0.8571429
6. Facets
6.1 facet_wrap
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() + facet_wrap(Species) +
theme(legend_position = "none")
6.2 Grid shape and free scales
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() + facet_wrap(Species, ncol = 2, scales = "free") +
theme(legend_position = "none")
Fixed scales share one domain; free scales train per panel.
fixed <- ggnext:::build_geometry(
ggnext(iris, aes(Sepal.Length, Sepal.Width)) + geom_point() +
facet_wrap(Species))
free <- ggnext:::build_geometry(
ggnext(iris, aes(Sepal.Length, Sepal.Width)) + geom_point() +
facet_wrap(Species, scales = "free"))
rbind(
fixed_p1 = fixed$panels[[1]]$x$domain,
fixed_p3 = fixed$panels[[3]]$x$domain,
free_p1 = free$panels[[1]]$x$domain,
free_p3 = free$panels[[3]]$x$domain
)
#> [,1] [,2]
#> fixed_p1 4.120 8.080
#> fixed_p3 4.120 8.080
#> free_p1 4.225 5.875
#> free_p3 4.750 8.050
6.3 facet_grid
mt <- transform(mtcars, cyl = factor(cyl), am = factor(am))
ggnext(mt, aes(disp, mpg)) + geom_point() + facet_grid(am, cyl) +
theme_minimal()
6.4 A layer without the faceting variable repeats in every panel
ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
geom_point(alpha = 0.6) +
geom_hline(3, color = "#C1462F", dash = "4,3") +
facet_wrap(Species) + theme_minimal()
7. Titles, labels and themes
7.1 The title block
ggnext(cars, aes(speed, dist)) +
geom_point() +
labs(
title = "Stopping distance rises with speed",
subtitle = "1920s road tests, 50 observations",
caption = "Source: datasets::cars",
tag = "A",
x = "Speed (mph)", y = "Distance (ft)"
) +
theme_minimal()
labs() merges across repeated calls; ggtitle(), xlab(), ylab() are
shorthands.
lab_plot <- ggnext(cars, aes(speed, dist)) + geom_point() +
labs(title = "first") + labs(subtitle = "second") + xlab("X") + ylab("Y")
b <- ggnext:::build_geometry(lab_plot)
unlist(b$labels[c("title", "subtitle")])
#> title subtitle
#> "first" "second"
7.2 The six presets
theme_demo <- function(th, nm) {
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(alpha = 0.85) + labs(title = nm) + th
}
theme_demo(theme_ggnext(), "theme_ggnext()")
theme_demo(theme_minimal(), "theme_minimal()")
theme_demo(theme_classic(), "theme_classic()")
theme_demo(theme_modern(), "theme_modern()")
theme_demo(theme_dark(), "theme_dark()")
theme_demo(theme_void(), "theme_void()")
7.3 Customising a theme
base picks the preset to start from; everything else applies over it.
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3) +
labs(title = "Custom theme") +
theme(
base = theme_minimal(),
panel_fill = "#FFF8F0",
grid_major_x = FALSE,
grid_color = "#E0D5C8",
plot_title_size = 22,
legend_position = "bottom",
point_palette = c("#2B6BE0", "#E05A2B", "#12A594")
)
7.4 All 35 theme settings
defaults <- ggnext:::THEME_DEFAULTS
data.frame(
setting = names(defaults),
default = vapply(defaults, function(v) {
if (length(v) == 0) "—" else paste(format(v), collapse = ", ")
}, character(1)),
row.names = NULL
)
#> setting default
#> 1 name ggnext
#> 2 background #FFFFFF
#> 3 panel_fill #F4F4F6
#> 4 grid_color #FFFFFF
#> 5 grid_color_minor
#> 6 axis_color #3A3A3A
#> 7 label_color #3A3A3A
#> 8 title_color #1A1A22
#> 9 subtitle_color #5A5A66
#> 10 strip_fill #E4E4EA
#> 11 strip_color #2A2A33
#> 12 legend_text_color #3A3A3A
#> 13 panel_border
#> 14 font Helvetica, Arial, sans-serif
#> 15 title_font
#> 16 tick_font_size 11
#> 17 title_font_size 13
#> 18 plot_title_size 17
#> 19 plot_subtitle_size 12
#> 20 caption_size 10
#> 21 strip_font_size 11
#> 22 legend_font_size 11
#> 23 title_face bold
#> 24 tick_len 5
#> 25 grid_major_x TRUE
#> 26 grid_major_y TRUE
#> 27 axis_line_x TRUE
#> 28 axis_line_y TRUE
#> 29 ticks_x TRUE
#> 30 ticks_y TRUE
#> 31 axis_text_x TRUE
#> 32 axis_text_y TRUE
#> 33 axis_title_x TRUE
#> 34 axis_title_y TRUE
#> 35 legend_position right
#> 36 point_palette —
#> 37 gradient_low
#> 38 gradient_high
Individual toggles, verified against the rendered SVG:
count_tag <- function(p, tag) {
svg <- render(p)
length(gregexpr(tag, svg, fixed = TRUE)[[1]]) *
(!grepl("^\\s*$", svg)) * as.integer(grepl(tag, svg, fixed = TRUE))
}
p0 <- ggnext(cars, aes(speed, dist)) + geom_point()
data.frame(
setting = c("grid on (default)", "grid_major_x = FALSE",
"grid_color = ''", "axis_text_x = FALSE"),
gridlines_or_labels = c(
count_tag(p0, "stroke=\"#FFFFFF\""),
count_tag(p0 + theme(grid_major_x = FALSE), "stroke=\"#FFFFFF\""),
count_tag(p0 + theme(grid_color = ""), "stroke=\"#FFFFFF\""),
count_tag(p0 + theme(axis_text_x = FALSE), "text-anchor=\"middle\"")
)
)
#> setting gridlines_or_labels
#> 1 grid on (default) 12
#> 2 grid_major_x = FALSE 7
#> 3 grid_color = '' 0
#> 4 axis_text_x = FALSE 2
7.5 Legends
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() + theme(legend_position = "bottom")
ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() + theme(legend_position = "none")
8. Layout geoms
8.1 geom_radar
radar <- data.frame(
axis = rep(c("Speed", "Power", "Range", "Cost", "Safety"), 2),
value = c(8, 6, 7, 4, 9, 5, 9, 4, 8, 6),
model = rep(c("A", "B"), each = 5)
)
ggnext(radar, aes(axis, value, color = model)) +
geom_radar() + coord_polar() + theme_minimal()
8.2 geom_treemap
tm <- data.frame(region = c("North", "South", "East", "West", "Central"),
revenue = c(52, 38, 27, 19, 11))
ggnext(tm, aes(size = revenue, label = region, color = region)) +
geom_treemap() + theme(legend_position = "none")
8.3 geom_sankey
flows <- data.frame(
from = c("Visited", "Visited", "Signed up", "Signed up"),
to = c("Signed up", "Left", "Purchased", "Churned"),
n = c(400, 600, 150, 250)
)
ggnext(flows, aes(x = from, xend = to, y = n)) + geom_sankey()
8.4 geom_network
net <- data.frame(from = c("A", "A", "B", "C", "D", "E", "B"),
to = c("B", "C", "C", "D", "E", "A", "E"))
ggnext(net, aes(x = from, xend = to)) + geom_network()
8.5 geom_chord
ch <- data.frame(from = c("A", "A", "B", "C"), to = c("B", "C", "C", "A"),
n = c(5, 3, 7, 2))
ggnext(ch, aes(x = from, xend = to, y = n)) + geom_chord()
8.6 geom_stream
st <- data.frame(
t = rep(1:8, 3),
v = c(2, 4, 6, 5, 3, 2, 4, 5, 1, 3, 5, 8, 6, 4, 3, 2, 5, 4, 3, 4, 6, 7, 5, 4),
grp = rep(c("a", "b", "c"), each = 8)
)
ggnext(st, aes(t, v, color = grp)) + geom_stream() + theme_minimal()
8.7 geom_bump
bump <- data.frame(
year = rep(2020:2024, 4),
rank = c(1, 2, 3, 3, 2, 2, 1, 1, 2, 1, 3, 3, 2, 1, 3, 4, 4, 4, 4, 4),
team = rep(c("A", "B", "C", "D"), each = 5)
)
ggnext(bump, aes(year, rank, color = team)) +
geom_bump() + scale_y_reverse() + theme_minimal()
8.8 geom_funnel
fn <- data.frame(
stage = factor(c("Visits", "Signups", "Trials", "Paid"),
levels = c("Visits", "Signups", "Trials", "Paid")),
n = c(10000, 3200, 1100, 420)
)
ggnext(fn, aes(stage, n, color = stage)) + geom_funnel() +
theme(legend_position = "none")
8.9 geom_parallel
sub <- iris[c(1, 20, 60, 80, 110, 140), ]
par_d <- data.frame(
id = rep(rownames(sub), 4),
var = rep(c("SL", "SW", "PL", "PW"), each = nrow(sub)),
val = c(sub$Sepal.Length, sub$Sepal.Width, sub$Petal.Length, sub$Petal.Width),
sp = rep(as.character(sub$Species), 4)
)
ggnext(par_d, aes(var, val, group = id, color = sp)) +
geom_parallel() + theme_minimal()
8.10 geom_upset
us <- data.frame(sets = c("A", "A&B", "B", "A&B&C", "C", "A&B",
"A", "B&C", "A&C", "A&B"))
ggnext(us, aes(label = sets)) + geom_upset()
9. Machine-learning geoms
9.1 geom_shap
shap <- data.frame(
feature = rep(c("age", "income", "tenure"), each = 40),
shap = c(rnorm(40, 0.3, 0.2), rnorm(40, -0.1, 0.3), rnorm(40, 0, 0.15)),
value = runif(120)
)
ggnext(shap, aes(shap, feature, color = value)) +
geom_shap() + geom_vline(0, dash = "3,3") +
labs(x = "SHAP value", y = NULL) + theme_minimal()
9.2 geom_partial_dependence
pdp <- data.frame(
x = rep(1:10, 8), id = rep(1:8, each = 10),
pred = as.vector(sapply(1:8, function(i) (1:10) * 0.1 * i + rnorm(10, 0, .15)))
)
ggnext(pdp, aes(x, pred, group = id)) +
geom_partial_dependence() + theme_minimal()
9.3 geom_roc
sc <- runif(300)
roc_d <- data.frame(score = sc, truth = rbinom(300, 1, sc))
ggnext(roc_d, aes(score = score, truth = truth)) +
geom_roc() + theme_minimal()
A perfect classifier gives an exact staircase:
perfect <- compute_stat(stat_roc(), list(
truth = c(1, 1, 0, 0), score = c(0.9, 0.8, 0.2, 0.1),
group = rep("all", 4)
))
data.frame(fpr = perfect$x, tpr = perfect$y)
#> fpr tpr
#> 1 0.0 0.0
#> 2 0.0 0.5
#> 3 0.0 1.0
#> 4 0.5 1.0
#> 5 1.0 1.0
#> 6 0.0 0.0
#> 7 1.0 1.0
9.4 geom_calibration
pr <- runif(400)
cal <- data.frame(pred = pr, obs = rbinom(400, 1, pr^1.3))
ggnext(cal, aes(pred, obs)) + geom_calibration() + theme_minimal()
9.5 geom_lift_gain
s2 <- runif(250)
lg_d <- data.frame(score = s2, y = rbinom(250, 1, s2))
ggnext(lg_d, aes(score = score, truth = y)) +
geom_lift_gain(type = "gain") + theme_minimal()
ggnext(lg_d, aes(score = score, truth = y)) +
geom_lift_gain(type = "lift") + theme_minimal()
9.6 geom_confusion_matrix
cm <- data.frame(
predicted = c(rep("cat", 14), rep("dog", 9), rep("bird", 6)),
actual = c(rep("cat", 11), rep("dog", 3), rep("dog", 7),
rep("cat", 2), rep("bird", 5), "cat")
)
ggnext(cm, aes(predicted, actual)) + geom_confusion_matrix() +
theme(legend_position = "none")
9.7 geom_residual
m <- lm(dist ~ speed, cars)
res <- data.frame(fitted = fitted(m), resid = resid(m))
ggnext(res, aes(fitted, resid)) + geom_residual() + theme_minimal()
9.8 geom_learning_curve
lc <- data.frame(
n = rep(c(50, 100, 200, 400, 800), 2),
score = c(.72, .80, .85, .88, .90, .66, .75, .81, .85, .88),
split = rep(c("train", "validation"), each = 5)
)
ggnext(lc, aes(n, score, color = split)) +
geom_learning_curve() + theme_minimal()
9.9 geom_silhouette
sil <- data.frame(
cluster = rep(c("1", "2", "3"), each = 25),
width = c(runif(25, .3, .9), runif(25, .1, .7), runif(25, -.1, .6))
)
ggnext(sil, aes(width, cluster, color = cluster)) +
geom_silhouette() + theme(legend_position = "none")
9.10 geom_embedding
emb <- data.frame(d1 = c(rnorm(40), rnorm(40, 4)),
d2 = c(rnorm(40), rnorm(40, 3)),
cluster = rep(c("a", "b"), each = 40))
ggnext(emb, aes(d1, d2, color = cluster)) +
geom_embedding() + theme_minimal()
9.11 geom_decision_boundary
gr <- expand.grid(x = seq(0, 1, 0.04), y = seq(0, 1, 0.04))
gr$cls <- ifelse(gr$x + gr$y > 1, "a", "b")
ggnext(gr, aes(x, y, color = cls)) +
geom_decision_boundary() + theme_minimal()
9.12 geom_forecast_band
fc <- data.frame(
t = 1:12, v = c(3, 4, 4, 5, 6, 6, 7, 8, 9, 10, 11, 12),
lo = c(rep(NA, 7), 6.5, 7, 7.5, 8, 8.5),
hi = c(rep(NA, 7), 9.5, 11, 12.5, 14, 15.5),
part = rep(c("actual", "forecast"), c(7, 5))
)
ggnext(fc, aes(t, v, ymin = lo, ymax = hi, group = part)) +
geom_forecast_band() + theme_minimal()
10. Clinical geoms
10.1 geom_km
km_d <- data.frame(
t = c(rexp(60, 0.08), rexp(60, 0.14)),
ev = rbinom(120, 1, 0.75),
arm = rep(c("Treatment", "Control"), each = 60)
)
ggnext(km_d, aes(time = t, status = ev, color = arm)) +
geom_km() + theme_minimal()
The product-limit estimate, checked by hand on four subjects — events at t = 1, 2, 4 and a censoring at t = 3, so S(1) = 3/4, S(2) = 1/2, S(4) = 0:
km_check <- compute_stat(stat_km(), list(
time = c(1, 2, 3, 4), status = c(1, 1, 0, 1), group = rep("all", 4)
))
curve <- which(km_check$role == "curve")
vapply(c(1, 2, 4),
function(tt) min(km_check$y[curve][km_check$x[curve] == tt]),
numeric(1))
#> [1] 0.75 0.50 0.00
10.2 geom_cuminc
ci <- data.frame(t = rexp(150, 0.1),
ev = sample(0:2, 150, replace = TRUE, prob = c(.4, .35, .25)))
ggnext(ci, aes(time = t, status = ev)) + geom_cuminc() + theme_minimal()
10.3 geom_forest
fp <- data.frame(
study = c("Trial A", "Trial B", "Trial C", "Trial D", "Pooled"),
hr = c(0.82, 0.71, 0.95, 0.88, 0.83),
lo = c(0.65, 0.52, 0.78, 0.70, 0.74),
hi = c(1.03, 0.97, 1.16, 1.10, 0.93),
weight = c(30, 22, 28, 20, 100)
)
ggnext(fp, aes(hr, study, ymin = lo, ymax = hi, size = weight)) +
geom_forest() + labs(x = "Hazard ratio (95% CI)", y = NULL) +
theme_minimal()
10.4 geom_swimmer
sw <- data.frame(
subject = paste0("S", 1:8),
months = c(4, 9, 14, 6, 20, 11, 17, 7),
response = c("PR", "CR", "CR", "SD", "PR", "SD", "CR", "PD"),
ongoing = c(FALSE, FALSE, TRUE, FALSE, TRUE, FALSE, TRUE, FALSE)
)
ggnext(sw, aes(months, subject, color = response, label = ongoing)) +
geom_swimmer() + labs(x = "Months", y = NULL) + theme_minimal()
10.5 geom_spider_response
sp <- data.frame(
month = rep(c(0, 2, 4, 6, 8), 4),
pct = c(0, -20, -35, -40, -42, 0, 10, 25, 40, 55,
0, -5, -10, -8, -12, 0, -30, -45, -50, -48),
subject = rep(c("S1", "S2", "S3", "S4"), each = 5)
)
ggnext(sp, aes(month, pct, color = subject)) +
geom_spider_response() +
labs(x = "Month", y = "% change from baseline") + theme_minimal()
10.6 geom_waterfall_response
wr <- data.frame(subject = paste0("S", 1:24),
pct = sort(runif(24, -78, 48), decreasing = TRUE))
ggnext(wr, aes(subject, pct)) + geom_waterfall_response() +
labs(y = "% change from baseline", x = NULL) +
theme(axis_text_x = FALSE)
10.7 geom_spaghetti
sg <- data.frame(
week = rep(0:5, 10), id = rep(1:10, each = 6),
score = as.vector(sapply(1:10, function(i) 50 + i + (0:5) * 2 + rnorm(6, 0, 3)))
)
ggnext(sg, aes(week, score, group = id)) + geom_spaghetti() + theme_minimal()
10.8 geom_bland_altman
a <- rnorm(80, 100, 12)
ba <- data.frame(method_a = a, method_b = a + rnorm(80, 2, 5))
ggnext(ba, aes(method_a, method_b)) + geom_bland_altman() + theme_minimal()
10.9 geom_dose_response
dr <- data.frame(
dose = rep(c(0.1, 1, 10, 100, 1000), each = 3),
resp = c(5, 7, 6, 18, 22, 20, 52, 48, 55, 82, 79, 85, 95, 97, 93)
)
ggnext(dr, aes(dose, resp)) + geom_dose_response() + scale_x_log10() +
theme_minimal()
10.10 geom_ae_heatmap
ae <- expand.grid(arm = c("Placebo", "Low", "High"),
ae = c("Nausea", "Fatigue", "Headache", "Rash"))
ae$pct <- c(5, 12, 22, 8, 15, 26, 3, 6, 11, 2, 9, 17)
ggnext(ae, aes(arm, ae, size = pct)) + geom_ae_heatmap() +
theme(legend_position = "none")
10.11 geom_shift
sh <- data.frame(
baseline = c("G0", "G0", "G1", "G1", "G2", "G0", "G1", "G0"),
followup = c("G0", "G1", "G1", "G2", "G2", "G0", "G0", "G1")
)
ggnext(sh, aes(baseline, followup)) + geom_shift() +
theme(legend_position = "none")
10.12 geom_consort
cs <- data.frame(
stage = c("Assessed for eligibility", "Randomised",
"Received allocation", "Completed follow-up", "Analysed"),
n = c(420, 300, 291, 276, 271)
)
ggnext(cs, aes(label = stage, size = n)) + geom_consort()
11. Interactivity and animation
Plots are static first. render(p) gives an SVG; + interact() switches
the default target to a self-contained HTML page.
p_int <- ggnext(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point()
nchar(render(p_int)) # static SVG
#> [1] 14498
nchar(render(p_int + interact())) # interactive HTML
#> [1] 43828
substr(render(p_int + interact()), 1, 15)
#> <ggnext interactive render: 15 characters>
#> Use render(p, file = ...) to save it, or cat(x) to see the source.
Interaction flags travel in the buffer:
opts <- ggnext:::build_geometry(
p_int + interact(tooltip = c("Species"), zoom = FALSE, brush = TRUE)
)$interaction
unlist(opts)
#> tooltip zoom brush
#> TRUE FALSE TRUE
Tooltips can be built from named data columns:
html <- render(p_int + interact(tooltip = c("Species", "Petal.Length")))
grepl("Species: setosa", html, fixed = TRUE)
#> [1] TRUE
Both targets consume the same geometry buffer, so mark counts always agree:
svg <- render(p_int)
html <- render(p_int, target = "interactive")
c(
svg_points = length(gregexpr("<circle ", svg, fixed = TRUE)[[1]]),
html_points = length(gregexpr("\"type\":\"circle\"", html, fixed = TRUE)[[1]]),
data_rows = nrow(iris)
)
#> svg_points html_points data_rows
#> 150 150 150
Animation reruns the pipeline per level of the transition variable, with axes fixed across frames:
anim_d <- data.frame(
x = rep(1:5, 3), y = c(1:5, (1:5) * 2, (1:5) * 3),
step = rep(c(1, 2, 3), each = 5)
)
p_anim <- ggnext(anim_d, aes(x, y)) + geom_point(size = 5) +
animate(step, duration = 600)
ab <- ggnext:::build_animation(p_anim, ggnext:::build_geometry(p_anim))
c(frames = length(ab$animation$frames),
labels = paste(unlist(ab$animation$labels), collapse = ","),
marks_in_frame_1 = length(ab$animation$frames[[1]][[1]][[1]]$marks))
#> frames labels marks_in_frame_1
#> "3" "1,2,3" "5"
An animated plot still renders statically:
p_anim
12. Exact data export
plot_data() returns precisely the values drawn — post-stat,
post-position, post-facet, in data units.
head(plot_data(ggnext(cars, aes(speed, dist)) + geom_point()))
#> x y
#> 1 4 2
#> 2 4 10
#> 3 7 4
#> 4 7 22
#> 5 8 16
#> 6 9 10
For a stat-backed layer it reflects what was computed, not the input:
plot_data(ggnext(iris, aes(Species, Sepal.Length)) + geom_boxplot())
#> x y lower middle upper ymin ymax role group
#> 1 1 NA 4.800 5.0 5.2 4.3 5.8 box all\r1
#> 2 2 NA 5.600 5.9 6.3 4.9 7.0 box all\r2
#> 3 3 NA 6.225 6.5 6.9 5.6 7.9 box all\r3
#> 4 3 4.9 NA NA NA NA NA outlier all\r3
Facets add a panel column:
fd <- plot_data(ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
geom_point() + facet_wrap(Species))
head(fd)
#> panel x y
#> 1 setosa 5.1 3.5
#> 2 setosa 4.9 3.0
#> 3 setosa 4.7 3.2
#> 4 setosa 4.6 3.1
#> 5 setosa 5.0 3.6
#> 6 setosa 5.4 3.9
table(fd$panel)
#>
#> setosa versicolor virginica
#> 50 50 50
Selecting layers and panels:
multi <- ggnext(cars, aes(speed, dist)) + geom_point() + geom_smooth()
length(plot_data(multi)) # one table per layer
#> [1] 2
nrow(plot_data(multi, layer = 1))
#> [1] 50
nrow(plot_data(multi, panel = 1))
#> NULL
Writing it out alongside the figure:
csv <- file.path(tempdir(), "figure-data.csv")
write_plot_data(ggnext(cars, aes(speed, dist)) + geom_point(), csv)
head(read.csv(csv), 3)
#> x y
#> 1 4 2
#> 2 4 10
#> 3 7 4
13. Rendering and the logo
p <- ggnext(cars, aes(speed, dist)) + geom_point()
svg_file <- file.path(tempdir(), "plot.svg")
html_file <- file.path(tempdir(), "plot.html")
render(p, file = svg_file)
render(p + interact(), file = html_file)
file.exists(c(svg_file, html_file))
#> [1] TRUE TRUE
render() output prints as a summary rather than dumping markup:
render(p)
The hex sticker is drawn by the package’s own SVG writer:
cat(ggnext_logo(width = 260))
cat(ggnext_logo(width = 260, style = "monogram"))
14. Extending the package
A geom is an S7 subclass plus one build_marks() method returning
primitives in normalized panel coordinates. Both renderers consume those
primitives, so a new geom needs no renderer changes.
GeomCross <- S7::new_class("GeomCross", parent = Geom,
constructor = function() {
S7::new_object(Geom(name = "cross",
default_params = list(size = 6, alpha = 1,
color = "#C1462F")))
}
)
S7::method(build_marks, GeomCross) <- function(geom, scaled) {
unlist(lapply(seq_along(scaled$x), function(i) {
r <- scaled$size[[i]] / 400
list(
ggnext:::mk_line(c(scaled$x[[i]] - r, scaled$x[[i]] + r),
rep(scaled$y[[i]], 2), scaled$color[[i]], width = 2),
ggnext:::mk_line(rep(scaled$x[[i]], 2),
c(scaled$y[[i]] - r, scaled$y[[i]] + r),
scaled$color[[i]], width = 2)
)
}), recursive = FALSE)
}
geom_cross <- function(mapping = NULL, data = NULL, ...) {
ggnext:::layer_new(GeomCross(), stat_identity(), mapping, data, list(...))
}
ggnext(cars, aes(speed, dist)) + geom_cross() + theme_minimal()
The custom geom works with every other part of the grammar — facets, scales, themes and the interactive target — without further work.
ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
geom_cross(size = 4) + facet_wrap(Species) + theme_minimal()
15. Error handling
Every failure mode below raises a clear, actionable message.
show_error <- function(expr) {
tryCatch({ force(expr); "no error" },
error = function(e) conditionMessage(e))
}
# A name that is not a column, where R finds a function of that name.
show_error(render(ggnext(mtcars, aes(disp, hp, color = class)) + geom_point()))
#> [1] "Aesthetic `color = class` evaluated to a function, not a data column. Is `class` a column in your data? (Columns present: mpg, cyl, disp, hp, drat, wt, qsec, vs, am, gear, carb)"
# Missing required aesthetics.
show_error(render(ggnext(cars, aes(speed)) + geom_point()))
#> [1] "geom_point() requires the aesthetic(s): y. Map them in aes(), or use a stat that computes them."
show_error(render(ggnext(cars, aes(speed, dist)) + geom_sankey()))
#> [1] "geom_sankey() requires the aesthetic(s): xend. Map them in aes(), or use a stat that computes them."
# No layers, no data.
show_error(render(ggnext(cars, aes(speed, dist))))
#> [1] "Cannot render a plot with no layers; add e.g. geom_point()."
show_error(render(ggnext() + geom_point()))
#> [1] "Layer has no data: supply data to ggnext() or to the layer."
# Impossible scales.
show_error(render(ggnext(data.frame(x = c(0, 1, 10), y = 1:3), aes(x, y)) +
geom_point() + scale_x_log10()))
#> [1] "A log10 scale requires strictly positive values."
show_error(scale_x_continuous(limits = c(10, 1)))
#> [1] "`limits` must be NULL or an increasing numeric vector of length 2."
# Unsupported aesthetics and theme settings.
show_error(aes(x, y, shape = z))
#> [1] "Unsupported aesthetic(s): shape. This spike supports: x, y, color, size, group, label, xmin, xmax, ymin, ymax, xend, yend, time, status, truth, score, sample."
show_error(theme(not_a_setting = 1))
#> [1] "Unknown theme setting(s): not_a_setting. Available: background, panel_fill, grid_color, grid_color_minor, axis_color, label_color, title_color, subtitle_color, strip_fill, strip_color, legend_text_color, panel_border, font, title_font, tick_font_size, title_font_size, plot_title_size, plot_subtitle_size, caption_size, strip_font_size, legend_font_size, title_face, tick_len, grid_major_x, grid_major_y, axis_line_x, axis_line_y, ticks_x, ticks_y, axis_text_x, axis_text_y, axis_title_x, axis_title_y, legend_position, point_palette, gradient_low, gradient_high."
show_error(ggnext(cars, aes(speed, dist)) + geom_point() + 42)
#> [1] "Cannot add an object of class <numeric> to a ggnext plot."
# Faceting on a variable that does not exist.
show_error(render(ggnext(iris, aes(Sepal.Length, Sepal.Width)) +
geom_point() + facet_wrap(NotAColumn)))
#> [1] "Faceting variable(s) not found in the data: NotAColumn"
16. Edge cases
edge <- function(label, expr) {
out <- tryCatch({ nchar(render(expr)); "rendered" },
error = function(e) paste("ERROR:", conditionMessage(e)))
data.frame(case = label, result = out)
}
do.call(rbind, list(
edge("single row",
ggnext(data.frame(x = 1, y = 1), aes(x, y)) + geom_point()),
edge("two rows",
ggnext(data.frame(x = 1:2, y = 1:2), aes(x, y)) + geom_point()),
edge("zero variance in x",
ggnext(data.frame(x = rep(5, 10), y = rnorm(10)), aes(x, y)) +
geom_point()),
edge("zero variance in both",
ggnext(data.frame(x = rep(1, 5), y = rep(2, 5)), aes(x, y)) +
geom_point()),
edge("NA in y",
ggnext(data.frame(x = 1:5, y = c(1, NA, 3, NA, 5)), aes(x, y)) +
geom_point()),
edge("very large values",
ggnext(data.frame(x = c(1e9, 2e9, 3e9), y = 1:3), aes(x, y)) +
geom_point()),
edge("very small values",
ggnext(data.frame(x = c(1e-9, 2e-9, 3e-9), y = 1:3), aes(x, y)) +
geom_point()),
edge("unicode in labels",
ggnext(cars, aes(speed, dist)) + geom_point() +
labs(title = "éàü 中文 — dash")),
edge("XML-special characters in labels",
ggnext(cars, aes(speed, dist)) + geom_point() +
labs(title = "a < b & c > d")),
edge("single facet level",
ggnext(iris[iris$Species == "setosa", ],
aes(Sepal.Length, Sepal.Width)) +
geom_point() + facet_wrap(Species)),
edge("many categories (palette recycles)",
ggnext(data.frame(g = letters[1:12], v = 1:12),
aes(g, v, color = g)) + geom_col())
))
#> case result
#> 1 single row rendered
#> 2 two rows rendered
#> 3 zero variance in x rendered
#> 4 zero variance in both rendered
#> 5 NA in y rendered
#> 6 very large values rendered
#> 7 very small values rendered
#> 8 unicode in labels rendered
#> 9 XML-special characters in labels rendered
#> 10 single facet level rendered
#> 11 many categories (palette recycles) rendered
17. Session information
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: aarch64-apple-darwin23
#> Running under: macOS Golden Gate 27.0
#>
#> Matrix products: default
#> BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
#>
#> locale:
#> [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
#>
#> time zone: Asia/Kolkata
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices datasets utils methods base
#>
#> other attached packages:
#> [1] ggnext_0.1.0 testthat_3.3.2
#>
#> loaded via a namespace (and not attached):
#> [1] vctrs_0.7.3 knitr_1.51 cli_3.6.6 xfun_0.57
#> [5] rlang_1.3.0 otel_0.2.0 purrr_1.2.2 pkgload_1.5.3
#> [9] renv_1.1.8 S7_0.2.2 glue_1.8.1 markdown_2.0
#> [13] rprojroot_2.1.1 pkgbuild_1.4.8 brio_1.1.5 evaluate_1.0.5
#> [17] ellipsis_0.3.3 fastmap_1.2.0 yaml_2.3.12 lifecycle_1.0.5
#> [21] memoise_2.0.1 compiler_4.6.1 fs_2.1.0 sessioninfo_1.2.4
#> [25] rstudioapi_0.19.0 R6_2.6.1 usethis_3.2.1 magrittr_2.0.5
#> [29] withr_3.0.2 tools_4.6.1 devtools_2.5.2 cachem_1.1.0
#> [33] desc_1.4.3