The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.
A ggplot2 chart shows data. An annotated chart tells a story — it directs the reader’s attention to the data points that matter and quantifies what changed. ggmemo adds that storytelling layer with two functions:
annotate_callout() points at a data row with an arrow
and label.annotate_change() draws a color-coded arrow between two
rows and labels the delta.Both return standard ggplot2 layers that you add to a plot with
+.
This vignette walks through a quarterly revenue dataset from a bare chart to a fully narrated one.
This bar chart is accurate, but it doesn’t guide the reader. Which quarter matters? Is the trend good or bad? The viewer has to figure that out on their own.
p <- ggplot(revenue, aes(x = quarter, y = revenue)) +
geom_col(fill = "steelblue", width = 0.6) +
labs(title = "2024 Quarterly Revenue ($K)", x = NULL, y = NULL) +
theme_minimal()
pannotate_callout() points at a specific row in your data
with an arrow and label. You identify the row with a filter expression —
the same syntax you use in dplyr::filter():
p +
annotate_callout(
revenue,
where = quarter == "Q4",
label = "Record quarter",
position = "top-left"
)
#> Registered S3 methods overwritten by 'ggpp':
#> method from
#> heightDetails.titleGrob ggplot2
#> widthDetails.titleGrob ggplot2The position argument controls where the label sits
relative to the data point. Options are "top-right" (the
default), "top-left", "bottom-right", and
"bottom-left".
annotate_change() draws a color-coded arrow between two
rows and labels the midpoint with the computed delta — green for
increases, red for decreases:
The format argument controls how the delta is displayed.
The default is "percent". Other options:
Absolute difference — shows the raw numeric change:
p +
annotate_change(
revenue,
from = quarter == "Q1",
to = quarter == "Q4",
value = revenue,
format = "absolute"
)Percentage points — useful when the data is already
expressed as a rate or percentage (e.g., savings rate, market share).
Using "percent" on rate data gives a misleading
percent-of-percent; "points" gives the straightforward
difference:
rates <- data.frame(
year = 2020:2023,
rate = c(3.5, 8.1, 5.4, 3.7)
)
ggplot(rates, aes(x = year, y = rate)) +
geom_line() +
geom_point() +
annotate_change(
rates,
from = year == 2020,
to = year == 2021,
value = rate,
format = "points"
) +
labs(title = "Unemployment Rate", y = "Rate (%)") +
theme_minimal()You can combine both functions on one chart. The callout names a moment; the change annotation quantifies what happened:
ggplot(revenue, aes(x = quarter, y = revenue)) +
geom_col(fill = "steelblue", width = 0.6) +
annotate_callout(
revenue,
where = quarter == "Q4",
label = "Record quarter",
position = "top-left"
) +
annotate_change(
revenue,
from = quarter == "Q1",
to = quarter == "Q4",
value = revenue
) +
labs(title = "2024 Quarterly Revenue ($K)", x = NULL, y = NULL) +
theme_minimal()You can stack several annotate_change() calls to show
quarter-over-quarter movement across the full series:
ggplot(revenue, aes(x = quarter, y = revenue)) +
geom_col(fill = "grey70", width = 0.6) +
annotate_change(revenue, from = quarter == "Q1",
to = quarter == "Q2", value = revenue) +
annotate_change(revenue, from = quarter == "Q2",
to = quarter == "Q3", value = revenue) +
annotate_change(revenue, from = quarter == "Q3",
to = quarter == "Q4", value = revenue) +
labs(title = "Quarter-over-Quarter Changes", x = NULL, y = NULL) +
theme_minimal()
#> Coordinate system already present.
#> ℹ Adding new coordinate system, which will replace the existing one.
#> Scale for y is already present.
#> Adding another scale for y, which will replace the existing scale.
#> Coordinate system already present.
#> ℹ Adding new coordinate system, which will replace the existing one.
#> Scale for y is already present.
#> Adding another scale for y, which will replace the existing scale.ggmemo works with Date x-axes. Here’s a savings rate time series with
a callout at the all-time low and a change annotation showing the
recovery. Note the use of nudge to manually position the
callout label — this overrides the automatic heuristic, which can miss
on wide data frames with many numeric columns (see Nudge below):
ggplot(economics, aes(x = date, y = psavert)) +
geom_line(colour = "grey40") +
annotate_callout(
economics,
where = date == as.Date("2005-07-01"),
label = "All-time low",
nudge = c(365, 1)
) +
annotate_change(
economics,
from = date == as.Date("2005-07-01"),
to = date == as.Date("2012-12-01"),
value = psavert,
format = "points"
) +
labs(
title = "U.S. Personal Savings Rate",
subtitle = "Recovery after the 2005 low",
x = NULL, y = "Savings rate (%)"
) +
theme_minimal()annotate_change() uses dark green for increases, dark
red for decreases, and grey for no change by default. You can supply
your own palette with the colors argument — a named vector
with entries up, down, and
flat:
p +
annotate_change(
revenue,
from = quarter == "Q1",
to = quarter == "Q4",
value = revenue,
colors = c(up = "#1B9E77", down = "#D95F02", flat = "#7570B3")
)annotate_change() supports arrow_type,
arrow_pad, and curvature for controlling the
arrow shape. annotate_callout() accepts
arrow = NULL to drop the arrow entirely and show just the
label:
p +
annotate_callout(
revenue,
where = quarter == "Q4",
label = "Record quarter",
position = "top-left",
arrow = NULL
) +
annotate_change(
revenue,
from = quarter == "Q3",
to = quarter == "Q4",
value = revenue,
arrow_type = "closed",
arrow_pad = 0.2,
curvature = -0.3
)Both functions accept ..., which passes additional
arguments through to the underlying ggplot2 layer. Use this to override
defaults like text size, background fill, or text colour:
p +
annotate_callout(
revenue,
where = quarter == "Q4",
label = "Record quarter",
position = "top-left",
size = 5,
fill = "lightyellow",
colour = "grey30"
)annotate_callout() automatically computes how far to
offset the label from the data point based on the data ranges. This
works well for simple two-column data frames. For wider data frames with
many numeric columns (like ggplot2::economics), the
heuristic may pick the wrong column’s range and produce a label that’s
too far away or too close.
You can override the heuristic by passing
nudge = c(x, y) in data units:
ggplot(economics, aes(x = date, y = unemploy)) +
geom_line() +
annotate_callout(
economics,
where = date == as.Date("2009-10-01"),
label = "Peak unemployment",
nudge = c(800, 1000)
) +
theme_minimal()Alternatively, you can pass a two-column subset of the data so the heuristic has less to guess:
These are the most common issues that come up when getting started with ggmemo.
factor()If your x-axis column is a character vector (common after
read.csv()), annotate_change() will error and
suggest converting it. When you do, always specify levels
to preserve the order in your data — plain factor() sorts
alphabetically:
as.Date()CSV files store dates as strings. If your date column looks like
"2024-01-15" but is class character, convert
it before plotting:
ggmemo will detect date-like strings and suggest this in the error message.
colour, not colorggplot2 uses British spelling internally. American color
works in most contexts, but it can produce a “Duplicated aesthetics”
warning when the function already sets a default colour.
Using colour avoids the warning:
The examples above use simple, two-column data. Here’s a more complex
chart — a grouped stacked bar chart of quarter-by-quarter scoring in an
NBA Finals game — that shows how annotate_change() works
when the annotation data differs from the plot data.
Brunson scored 52% of the Knicks’ fourth-quarter points after
contributing just 20% in Q3. To annotate that shift, we pass a separate
two-row data frame to annotate_change() with the x
positions and values we want to compare, use a custom
label, and set expand_y = FALSE so the arrow
doesn’t push the y-axis beyond the bars. We also override the default
green with the same orange used for Brunson’s bars so the annotation
feels integrated:
scoring <- data.frame(
Quarter = rep(c("Q1", "Q2", "Q3", "Q4"), 4),
Team = rep(c("Knicks", "Knicks", "Spurs", "Spurs"), each = 4),
Player = rep(c("Brunson", "Rest of Knicks", "Wemby", "Rest of Spurs"), each = 4),
Points = c(
5, 7, 5, 13,
23, 20, 20, 12,
8, 9, 7, 4,
16, 17, 19, 15
)
)
scoring$Quarter <- factor(scoring$Quarter, levels = c("Q1", "Q2", "Q3", "Q4"))
scoring$Player <- factor(scoring$Player,
levels = c("Rest of Spurs", "Wemby", "Rest of Knicks", "Brunson"))
scoring$x_pos <- as.numeric(scoring$Quarter) +
ifelse(scoring$Team == "Knicks", -0.2, 0.2)
team_totals <- aggregate(Points ~ Quarter + Team + x_pos, data = scoring, FUN = sum)
team_totals <- team_totals[!duplicated(team_totals[, c("Quarter", "Team")]), ]
ggplot(scoring, aes(x = x_pos, y = Points, fill = Player)) +
geom_col(width = 0.35) +
geom_text(
data = team_totals,
aes(x = x_pos, y = Points, label = Points, fill = NULL),
vjust = -0.4, size = 5, fontface = "bold"
) +
scale_fill_manual(
values = c(
"Brunson" = "#E86A00",
"Rest of Knicks" = "#FDCB8B",
"Wemby" = "#6D6D6D",
"Rest of Spurs" = "#B8B8B8"
),
name = NULL
) +
scale_x_continuous(breaks = 1:4, labels = c("Q1", "Q2", "Q3", "Q4")) +
scale_y_continuous(expand = expansion(mult = c(0, 0.12))) +
annotate_change(
data.frame(x_pos = c(2.8, 3.8), Points = c(5, 13)),
from = x_pos == 2.8,
to = x_pos == 3.8,
value = Points,
format = "percent",
expand_y = FALSE,
label = "20% → 52% of team pts",
colors = c(up = "#E86A00", down = "#B22222", flat = "#808080")
) +
coord_cartesian(clip = "off") +
labs(
title = "Quarter-by-Quarter Scoring — Knicks vs Spurs",
x = NULL, y = "Points"
) +
theme_minimal(base_size = 14) +
theme(
legend.position = "bottom",
panel.grid.major.x = element_blank(),
plot.margin = margin(10, 20, 10, 10)
)
#> Coordinate system already present.
#> ℹ Adding new coordinate system, which will replace the existing one.Key techniques used here:
scoring data frame, but annotate_change()
receives a minimal two-row frame with just the x positions and values to
compare.label that tells the story
directly.expand_y = FALSE — prevents the arrow
from adding extra space above the bars.colors = c(up = "#E86A00", ...) matches the arrow to
Brunson’s bar color so the annotation looks intentional.ggmemo is focused on two tasks: callout annotations and change annotations for business charts. For other annotation needs, these packages are worth knowing:
These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.