# Narrating Business Charts with ggmemo

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.

## The data

    library(ggplot2)
    library(ggmemo)

    revenue <- data.frame(
      quarter = factor(c("Q1", "Q2", "Q3", "Q4"),
                       levels = c("Q1", "Q2", "Q3", "Q4")),
      revenue = c(120, 145, 132, 158)
    )

## A chart without annotations

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()
    p

## Calling out a key data point

`annotate_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  ggplot2

The `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"`.

## Showing the change between two points

`annotate_change()` draws a color-coded arrow between two rows and labels the midpoint with the computed delta — green for increases, red for decreases:

    p +
      annotate_change(
        revenue,
        from = quarter == "Q1",
        to = quarter == "Q4",
        value = revenue
      )

### Format options

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()

## Putting it all together

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()

### Multiple change annotations

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.

### Time series

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()

## Customization

### Colors

`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")
      )

### Arrow styling

`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
      )

### Label styling

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"
      )

### Nudge

`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:

    annotate_callout(
      economics[, c("date", "unemploy")],
      where = date == as.Date("2009-10-01"),
      label = "Peak unemployment"
    )

## Common mistakes

These are the most common issues that come up when getting started with ggmemo.

### Character columns need `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:

    # Alphabetical — probably not what you want
    data$month <- factor(data$month)

    # Preserves data order
    data$month <- factor(data$month, levels = unique(data$month))

### Date-like strings need `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:

    data$date <- as.Date(data$date)

ggmemo will detect date-like strings and suggest this in the error message.

### Use `colour`, not `color`

ggplot2 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:

    annotate_callout(..., colour = "red")

### `size` controls text, not the label box

The `size` argument sets text size (in mm, matching ggplot2 conventions). To adjust the padding around the text inside the label box, use `label.padding`:

    annotate_callout(..., size = 5, label.padding = unit(0.4, "lines"))

## Real-world example: NBA Finals scoring breakdown

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:

- Separate annotation data — the plot uses the full `scoring` data frame, but `annotate_change()` receives a minimal two-row frame with just the x positions and values to compare.
- Custom label — instead of the auto-computed delta, we pass a descriptive `label` that tells the story directly.
- `expand_y = FALSE` — prevents the arrow from adding extra space above the bars.
- Custom colors — `colors = c(up = "#E86A00", ...)` matches the arrow to Brunson’s bar color so the annotation looks intentional.

## What ggmemo doesn’t do

ggmemo is focused on two tasks: callout annotations and change annotations for business charts. For other annotation needs, these packages are worth knowing:

- Label overlap avoidance: ggrepel automatically repositions text labels to avoid overlaps.
- Precise NPC positioning: ggpp supports normalized parent coordinates and is the package ggmemo builds on.
- Interactive annotations: plotly and ggiraph support hover tooltips, click events, and zoom.
- Themes and styling: ggthemes, hrbrthemes, and bbplot provide polished chart themes for business and publication contexts.