Grammar of Graphics on ‘base’ Graphics

Guangchuang Yu

2026-10-18

plotbb puts a small grammar of graphics on top of R’s base graphics. It is not a replacement for base graphics and not a reimplementation of ggplot2: a bbplot is still drawn by graphics::points(), lines(), rect() and polygon(), and any base call can still be used. What the grammar adds is a way to say what a figure is made of — data, mappings, layers, scales, labels, facets — and a set of rules that make those pieces combine predictably.

Version 1.0.0 brings the pieces together: one trained scale per aesthetic, shared by the layers and the legend, with separate col and fill scales; automatic outside legends; positional scales; and horizontal orientations for the statistical layers. The vignette is organised the way the grammar is meant to be learned rather than the way the functions are exported. Every section states a rule, shows it in a running example, and then says what the rule implies — including where it stops. The reference at the end lists the layers, the aesthetics they map, and the known limits, so that a future version can extend this document without rewriting it.

A note on reading: every code block runs with the package as installed, in order, on the same small set of data (mtcars, plus a few summaries built from it). The object p is introduced once and reused — redefined once, in the section on inheritance, to add a colour mapping — so later chunks can focus on the component being discussed instead of re-stating the whole plot. Terms are used in a fixed sense throughout: a plot object is the description returned by bbplot(); a component is anything added with +; a layer is a component that draws data; a mapping is an expression in bb_aes(); a constant is a fixed value such as col = "tomato". “Stable” means the behaviour is documented and tested here; anything planned for a later release is marked as such and is not written as if it already worked.

Some familiarity with base graphics is assumed — enough to know that par() changes device state and that points() draws on top of whatever is already there. The grammar does not hide those mechanics; it gives them a structure.

The grammar in one page

A plot is a plot object plus a series of components added with +:

  1. bbplot(data, mapping) creates the object: the data every layer can see and the mapping every layer can inherit.
  2. Layers (bb_point(), bb_line(), bb_bar(), …) are added to it. Each layer may bring its own data and mapping, and draws in the order it was added.
  3. Non-layer components change the figure rather than draw data: labels, themes, scales, legends, facets and grid lines.

The separation matters. A layer is the only kind of component that consumes data; everything else adjusts the canvas, the decorations or the mapping from values to ink. That is why a scale can be added after the layer that uses it, and why a theme does not need to know which layers exist. It also explains the division of labour between bbplot() and the layers: bbplot() sets the defaults, and each layer says what it contributes on top of them.

The plot object is a description, not a drawing. Nothing is rendered when the object is built; it is drawn when you print() it (or at the end of an expression in a document such as this one). This deferred drawing is what makes it possible to add a scale after a layer and still have the layer use it. It is also where the graphics parameters are restored: print.bbplot() snapshots par(), draws, and restores the snapshot, so a bbplot never leaves the device dirty — not even when a layer fails.

+ is therefore deliberately narrow: it adds one component to one plot. It is not a layout operator and does not merge two complete plots; for several plots side by side, build them separately and arrange them with aplot::plot_list() as the examples below do. Third-party packages can still extend the set of components: implement bbplot_add() for your class and return the modified plot.

p <- bbplot(mtcars, bb_aes(mpg, disp))
p + bb_point(pch = 19)

Data and mapping

bb_aes() records mappings: expressions evaluated against the data, not values computed now. bb_aes(mpg, disp) means “the columns mpg and disp”, and bb_aes(mpg, log(disp)) works as well because the expression is evaluated per layer, in the layer’s data. The practical consequence is that a mapping is portable: the same bb_aes(mpg, disp) can be inherited by a layer that brings a different data frame, and it will resolve against that frame.

bb_aes() captures only the aesthetics you actually write. There is no inheritance inside bb_aes() itself and no silent completion of missing aesthetics: a layer that needs x and y but receives neither fails with a message, instead of quietly plotting against an index. This is intentional — the grammar is explicit about what is mapped rather than guessing.

bbplot(mtcars, bb_aes(mpg, log(disp), col = factor(cyl))) +
  bb_point(pch = 19)

A mapping can also refer to objects from the calling environment; it is not restricted to columns of the data. The expression keeps the environment it was written in, so a variable defined just before the plot is visible inside the layer. This is convenient for thresholds and highlights, and it is also the reason a mapping should not depend on objects that change after the plot is built: the expression is evaluated when the plot is drawn, in the layer’s data, with that captured environment.

highlight <- 300
bbplot(mtcars, bb_aes(mpg, disp, col = disp > highlight)) +
  bb_point(pch = 19)

Layers override the plot mapping

Every layer inherits the plot’s data and mapping, and may override them. The inheritance is per aesthetic, not per mapping object: a local mapping is merged into the inherited one name by name. This is what lets a layer change the colour while keeping the coordinates, and it is why the examples below write bb_aes(col = ...) without repeating x and y.

The asymmetry between mapping and data is deliberate. A mapping is the part of the plot that changes how values become ink, so a layer that opts out should state it completely; data is the part that says which observations exist, and a layer that only wants to add an annotation should not have to copy the frame it was already given. data defaults to inheritance, and only an explicit data = replaces it.

p <- bbplot(mtcars, bb_aes(mpg, disp, col = factor(cyl)))

a <- p + bb_point(pch = 19)                                  # inherit everything
b <- p + bb_point(bb_aes(col = factor(am)), pch = 19)        # override col only
c <- p + bb_point(bb_aes(mpg, disp), inherit = FALSE, pch = 19)  # no inheritance

plot_list(a, b, c, ncol = 3, tag_levels = "A")

A second data frame is a normal layer argument. Here the points come from the plot data and the group means come from a summary that the layer brings itself; the axis ranges cover both.

means <- mtcars |>
  group_by(cyl) |>
  summarise(mpg = mean(mpg), disp = mean(disp), .groups = "drop")

bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_point(pch = 19, col = "grey60") +
  bb_point(bb_aes(mpg, disp), data = means, col = "firebrick", pch = 17, cex = 2)

With inherit = FALSE the layer has to state everything it draws. Leaving x and y out is an error rather than a silent fallback to the global mapping, because a layer that draws nothing is harder to diagnose than a layer that says which mapping it is missing:

p + bb_point(bb_aes(col = factor(am)), inherit = FALSE)
Error:
! `bb_point` has no data to draw: `y` maps to an empty vector.

Constants are not mappings

A mapping varies with the data; a constant does not. Both are written in the layer call, but they mean different things, and the difference is the one most often worth getting right. Think of a mapped aesthetic as a question asked of the data (“which colour does this row get?”) and a constant as a parameter of the pen (“this layer is drawn in firebrick”). The first can be trained by a scale and summarised in a legend; the second cannot, because there is nothing to train.

pal <- c(`4` = "#1b9e77", `6` = "#d95f02", `8` = "#7570b3")

mapped <- p + bb_point(pch = 19) + bb_scale_col_manual(pal)
constant <- p + bb_point(pch = 19, col = "firebrick") + bb_scale_col_manual(pal)

plot_list(mapped, constant, ncol = 2, tag_levels = "A")

When an aesthetic is both mapped and given as a constant on the same layer, the constant wins. The constant is the more specific statement — it is written for this layer, at this point in the call — while the mapping is a default that the layer chose to override:

p + bb_point(pch = 19, col = "tomato")

A constant does more than win the draw: it removes the mapping of that aesthetic for the layer. The aesthetic is not trained by a scale, not redrawn by a later scale, and — for the statistical layers — it does not group either. The rule is applied once, before the layer is built, so every layer follows it.

Aesthetics that can be both mapped and fixed are col, fill, pch, lty, cex and (for text) label. For a layer that draws one element per row, a constant is either a single value or one value per drawn row; a row-aligned constant follows the same rows as the data, so dropping rows with NA or infinite coordinates never shifts it onto the wrong point. Any other length is an error, not silently recycled.

p + bb_point(col = c("red", "blue"))
Error:
! `col` in `bb_point` has length 2, but 32 row(s) were mapped; it must be length 32 or 1.
d <- data.frame(x = c(1, NA, 3), y = c(1, 2, 3))
bbplot(d, bb_aes(x, y)) +
  bb_point(col = c("red", "green", "blue"), pch = 19, cex = 2)

The second point is dropped, and the third keeps its own colour: the constant vector is row-aligned, not recycled.

Summary layers are the exception, because their constants are graphical parameters rather than row-level aesthetics: bb_hist() and bb_density() use col/fill as the fill of the bars or polygon, and bb_boxplot()/bb_bar() use col for one box or bar each. There a vector means one value per bar, box or group, exactly as in the base function they wrap.

Order of layers

Layers are drawn in the order they were added, so later layers sit on top. Moving a layer in the chain changes what covers what, and nothing else. There is no z-order attribute, no priority number and no automatic reordering: the + chain is the drawing order. This is the base graphics model kept intact, and it is the reason semi-transparent fills behave the way they do — two overlapping polygons mix in the order they are drawn.

points_then_fit <- bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_point(pch = 19, col = "grey50") +
  bb_lm(col = "firebrick", lwd = 2)

fit_then_points <- bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_lm(col = "firebrick", lwd = 2) +
  bb_point(pch = 19, col = "grey50")

plot_list(points_then_fit, fit_then_points, ncol = 2, tag_levels = "A")

Components that are not layers

+ also accepts components that change the figure instead of drawing data. Most of them take effect when the component is added, so their position in the chain is not significant; where order does matter it is stated explicitly. The two notable cases are labels, which are last-one-wins, and scales, which are read when the plot is drawn so the last scale for an aesthetic is the one in effect. Themes are applied at the start of drawing, which is why a theme can be added after the layers it affects.

p +
  bb_grid(col = "grey85", lty = "dashed") +
  bb_point(pch = 19) +
  bb_labs(title = "mtcars", sub = "displacement versus mileage",
          xlab = "miles per gallon", ylab = "displacement") +
  bb_theme_minimal()

Labels

bb_labs() sets several labels at once; bb_title(), bb_sub(), bb_xlab() and bb_ylab() set one each. A later label replaces an earlier one for the same element. Labels are not drawn by the layers: they are applied once, after the layers, by the plot’s print method, so they always sit above the data and cannot be covered by a later layer.

Theme

bb_theme() takes graphics::par() settings and applies them to this plot only. A theme has no side effect on the device: it is applied when the plot is drawn and the previous parameters are restored afterwards, also when a layer fails. That is what makes predefined themes safe to reuse, and it is why the two plots below can share a device without the first one’s background leaking into the second.

This behaviour is the difference between the grammar and calling par() directly. par() is device state: it persists until something resets it, so a plot that changes it affects whatever is drawn next. A theme is plot state: it travels with the object, is applied inside the print method, and is undone before control returns. If you find yourself wanting par() outside the plot, the device-level themes below are the supported way to express it.

a <- p + bb_point(pch = 19) + bb_theme(bg = "lemonchiffon", mar = c(4, 4, 3, 1)) +
  bb_title("bb_theme()")
b <- p + bb_point(pch = 19) + bb_title("untouched device")
plot_list(a, b, ncol = 2, tag_levels = "A")

Presets are combinations of bb_theme() settings:

a <- p + bb_point(pch = 19, cex = 1.6) + bb_theme_expand() + bb_theme_deepblue() +
  bb_title("bb_theme_deepblue()")
b <- p + bb_point(pch = 19) + bb_theme_minimal() + bb_title("bb_theme_minimal()")
c <- p + bb_point(pch = 19) + bb_theme_bw() + bb_title("bb_theme_bw()")
d <- p + bb_point(pch = 19) + bb_theme_grey() + bb_title("bb_theme_grey()")
plot_list(a, b, c, d, ncol = 2, tag_levels = "A")

Device-level themes

When you want a theme to last beyond one plot, use set_bb_theme() and unset_bb_theme(), or with_bb_theme() for a block of code. with_bb_theme() restores the previous state even if the code fails, and can be nested: the restoration is scoped to the block rather than to the whole session, so a failed example does not leave the device in a different state than it found it.

with_bb_theme(bb_theme_deepblue(), {
  bbplot(mtcars, bb_aes(mpg, disp)) + bb_point(pch = 19, cex = 1.6)
})

Scales and legends

A scale turns a mapped aesthetic into ink. It is trained from the data the plot draws, then used both by the layers and by the legend, so the two can never disagree about what a value looks like.

Training

Training happens when the plot is printed, not when a layer is added. It collects the mapped values from the plot-level mapping and from every layer’s effective mapping — including layers that bring their own data — and stores the result in the scale. Two consequences matter in practice:

Legends are automatic: every mapped aesthetic (col, fill, pch, lty, cex) gets an outside legend unless bbplot(auto_legend = FALSE) or an explicit bb_legend() component for that aesthetic takes over. The automatic legend for a layer-local mapping is tied to that layer, so its title comes from the layer mapping rather than the plot mapping. A free-colour facet skips automatic outside legends because one legend cannot describe several colour domains.

Facets train once on the full plot and share the resulting domain by default: the same value gets the same colour in every panel. free_colour = TRUE opts into a per-panel col/fill domain (an explicit colour scale is required; the default mapping is value-based and therefore identical across panels). A free colour facet cannot use an outside legend, because one legend cannot describe several domains.

Discrete scales

For a discrete mapping, levels come from factor levels first (in factor order), then from character values in first-appearance order. drop = TRUE (the default) keeps only levels that occur in the data; drop = FALSE keeps all factor levels. A named manual palette matches by name, an unnamed one maps in level order, and a value that the scale does not cover maps to na.value with a warning instead of being silently recycled.

pal <- c(`4` = "#1b9e77", `6` = "#d95f02", `8` = "#7570b3")
p + bb_point(pch = 19) + bb_scale_col_manual(pal) + bb_legend()

limits restricts the trained domain: for a discrete scale it also defines which levels are shown.

p + bb_point(pch = 19) +
  bb_scale_col_manual(pal, limits = c("4", "6")) +
  bb_legend()

Continuous scales

A continuous mapping interpolates between colours over a trained numeric domain. limits fixes the domain; oob = "squish" (the default) clamps values outside it, oob = "censor" maps them to na.value. A zero-width domain is widened by half a unit, and an all-missing domain leaves every value at na.value. minor_breaks adds small ticks between the labelled breaks of the colour bar.

bbplot(mtcars, bb_aes(mpg, disp, col = hp)) +
  bb_point(pch = 19) +
  bb_scale_col_gradient(low = "grey85", high = "firebrick") +
  bb_legend(title = "horsepower")

Breaks and labels are shared by the layer colours and the colour bar:

bbplot(mtcars, bb_aes(mpg, disp, col = hp)) +
  bb_point(pch = 19) +
  bb_scale_col_gradient(low = "grey85", high = "firebrick",
                        breaks = c(100, 200, 300),
                        labels = c("low", "mid", "high")) +
  bb_legend(title = "horsepower", orientation = "horizontal",
            position = "bottomright")

Fill is its own aesthetic

col and fill are separate aesthetics with separate scales. A layer that maps fill uses a fill scale if one is given and the default fill mapping otherwise; it no longer borrows the col scale. This makes the rule explicit and removable in one line: add a bb_scale_fill_*() scale.

bbplot(mtcars, bb_aes(factor(cyl), mpg, fill = factor(cyl))) +
  bb_density(alpha = 0.5) +
  bb_scale_fill_manual(c(`4` = "#1b9e77", `6` = "#d95f02", `8` = "#7570b3")) +
  bb_legend(aesthetic = "fill")

Other aesthetics have their own scales, and each legend names its aesthetic:

a <- bbplot(mtcars, bb_aes(mpg, disp, col = factor(cyl), pch = factor(gear), cex = hp)) +
  bb_point() +
  bb_scale_cex_continuous(range = c(0.8, 2)) +
  bb_legend(aesthetic = "col", position = "topleft") +
  bb_legend(aesthetic = "pch", position = "bottomleft")

b <- bbplot(mtcars, bb_aes(mpg, disp, group = gear, col = factor(gear))) +
  bb_lm(lwd = 2) +
  bb_scale_col_manual(c(`3` = "#1b9e77", `4` = "#d95f02", `5` = "#7570b3")) +
  bb_legend(position = "topleft")

plot_list(a, b, ncol = 2, tag_levels = "A")

A mapped line type is drawn by the line-like layers, not only shown in the legend:

bbplot(mtcars, bb_aes(mpg, disp, group = gear, lty = factor(gear))) +
  bb_lm(lwd = 2) +
  bb_scale_lty_manual(c(`3` = "solid", `4` = "dashed", `5` = "dotted")) +
  bb_legend(aesthetic = "lty", position = "topleft")

Legend placement

bb_legend() draws a discrete legend with graphics::legend() and a continuous colour bar with a dedicated function; both read the same trained scale as the layers. orientation = "horizontal" turns the colour bar, and outside = TRUE moves any legend into a dedicated layout column so it cannot cover the data. In a faceted plot an inside legend is repeated in each panel; outside = TRUE draws it once for the whole figure.

p + bb_point(pch = 19) + bb_legend(aesthetic = "col", outside = TRUE)

By default the legend describes the whole plot, through the plot-level mapping and the shared scale. bb_legend(layer = ) selects one layer’s mapping and uses its default title (layer is a 1-based index or a layer name); the colours still come from the shared scale, so this selects the explained mapping, not a second domain. style = "bar" turns a discrete col/fill legend into adjacent colour blocks, and title_width wraps a long title.

layer_data <- data.frame(
  x = 1:6, y = 1:6,
  g = factor(rep(c("a", "b"), 3)),
  h = factor(rep(c("u", "v", "w"), each = 2))
)
bbplot(layer_data, bb_aes(x, y, col = h)) +
  bb_point() +
  bb_line(bb_aes(group = g, col = g)) +
  bb_legend(layer = 2, title = "line grouping")

Free colour facets are an opt-in, not the default:

free_data <- data.frame(
  x = 1:6, y = 1:6,
  panel = rep(c("a", "b"), each = 3),
  g = factor(c("u", "v", "u", "v", "w", "w"))
)
bbplot(free_data, bb_aes(x, y, col = g)) +
  bb_point() +
  bb_scale_col_manual(c(u = "red", v = "blue", w = "green")) +
  bb_facet_wrap(~panel, ncol = 2, free_colour = TRUE)

Positional scales

The x and y axes have their own scales, separate from the aesthetic scales. bb_scale_x_continuous() and bb_scale_y_continuous() take limits, breaks and labels; bb_scale_x_log10()/bb_scale_y_log10() are log10 shortcuts, and bb_scale_x_date()/bb_scale_y_date() use Date limits and graphics::axis.Date(). Limits are the final axis range, so a point outside them is clipped rather than stretching the canvas.

pos_data <- data.frame(x = 1:20, y = seq(1, 100, length.out = 20))
bbplot(pos_data, bb_aes(x, y)) +
  bb_point() +
  bb_scale_x_continuous(limits = c(5, 15),
                        breaks = c(5, 10, 15),
                        labels = c("low", "mid", "high")) +
  bb_scale_y_log10(limits = c(1, 100))

A date axis keeps the Date class for its limits and breaks:

date_data <- data.frame(date = as.Date("2024-01-01") + 0:20,
                        value = 1:21)
bbplot(date_data, bb_aes(date, value)) +
  bb_point() +
  bb_scale_x_date(format = "%b %d")

bb_coord_flip() swaps the axes, including the axis labels and positional scales. It supports coordinate-symmetric layers (bb_point(), bb_line(), bb_text(), bb_segment(), bb_tile(), custom layers and bb_hline()/bb_vline()) and layers with an orientation model: violin, boxplot, bar, errorbar, ribbon, density, histogram and smooth switch to the opposite orientation. bb_lm() and bb_signif() have no orientation model and raise an error instead of drawing sideways.

bbplot(pos_data, bb_aes(x, y)) +
  bb_point() +
  bb_hline(50) +
  bb_coord_flip()

Facets

bb_facet_wrap() splits the plot into panels by a formula. The formula is evaluated against the plot data to produce a panel key per row; the panels are the sorted unique keys. Because each panel draws the same plot object with a subset of the data, the layers, their order, and their constants apply unchanged inside every panel — faceting changes which rows a panel sees, not how a panel is drawn.

Layer data follows the panels: each panel takes the rows of the layer data that carry the panel’s facet value. Row-aligned layer data with the same number of rows as the plot data falls back to the panel row positions, and a layer with no rows in a panel is simply absent from that panel. Data that has neither the facet variable nor the same row count cannot be split, so it is drawn in every panel and the plot warns instead of failing silently. The three cases are worth keeping in mind when a layer brings its own summary: giving it the facet column is always the safest option.

The formula is evaluated as an expression. The same expression is reused on each layer’s data, so a combined or transformed key behaves the same for the plot data and for a layer that brings its own rows.

p + bb_point(pch = 19) + bb_facet_wrap(~cyl, ncol = 3)

By default all panels share one range, which makes the panels comparable. A free scale computes the range per panel from that panel’s data (including layer data), which makes each panel use its space fully but hides differences in level. The choice is a reading decision, not a cosmetic one; the grammar only provides the switch.

p + bb_point(pch = 19) + bb_facet_wrap(~am, ncol = 2, scales = "free_y")

Because the facet is an ordinary expression, a combined key needs no special syntax:

p + bb_point(pch = 19) +
  bb_facet_wrap(~paste(cyl, am, sep = "/"), ncol = 2)

Unused factor levels are dropped by default. drop = FALSE keeps them as empty panels, which makes a missing group visible instead of silently absent; an empty panel uses the global limits even on free scales. labeller changes the strip text and strip_width wraps a long strip, with the top margin following the wrapped number of lines.

facet_data <- data.frame(
  x = 1:6, y = 1:6,
  f = factor(rep(c("a", "b"), each = 3), levels = c("a", "b", "c"))
)
bbplot(facet_data, bb_aes(x, y)) + bb_point() +
  bb_facet_wrap(~f, ncol = 3, drop = FALSE,
                labeller = function(key) paste("group", key),
                strip_width = 10)

bb_facet_grid() puts one variable on rows and another on columns. Every combination of row and column keys gets a panel; a combination without rows is drawn empty. Column labels sit on top, row labels on the right. With free scales, x is shared within a column and y within a row, matching the grid reading order.

bbplot(mtcars, bb_aes(mpg, disp)) + bb_point() +
  bb_facet_grid(rows = ~cyl, cols = ~am)

Statistical and annotation layers

The statistical layers take the same data and mapping arguments as the geometric ones and contribute their own ranges. “Statistical” here means that the layer computes something from the rows before drawing: a histogram bins a numeric variable, a density estimates its distribution, a boxplot summarises it by group, a fitted line estimates a trend, a violin estimates a distribution per group, a smoother fits an explicit model, and bb_signif() draws comparisons. There is no separate statistic API to learn — the summary is part of the layer, and its result is what takes part in the ranges.

bb_hist() and bb_density() share one grouping rule with the other statistical layers: an explicit group mapping wins, then a discrete x, then col, then fill. With orientation = "horizontal", the statistic is drawn on x: a horizontal density summarises y (or x when y is a discrete group), and a horizontal histogram puts counts on x. bb_smooth() follows the same idea: horizontal fits x against y and draws the confidence band sideways. A constant colour is never a grouping. The rule is stated once and used both when drawing and when computing the range, so a grouped histogram is scaled to the tallest single group rather than to the pooled counts.

Two consequences follow from summarising. First, a summary layer consumes rows, not just coordinates: bb_hist() needs one numeric variable, and the other axis is a discrete group rather than a second numeric dimension. In the horizontal orientation that numeric variable is y; in the vertical orientation it is x. Second, constants on a summary layer are graphical parameters for the resulting bars, boxes or polygons rather than row-level aesthetics, which is why they are documented separately from the per-row layers.

a <- bbplot(mtcars, bb_aes(factor(cyl), mpg)) + bb_boxplot(col = "grey90")
b <- bbplot(mtcars, bb_aes(mpg)) + bb_hist(col = "grey80", border = "white")
c <- bbplot(mtcars, bb_aes(mpg)) + bb_density(fill = "steelblue", alpha = 0.5)
d <- bbplot(mtcars, bb_aes(factor(cyl), mpg, fill = factor(cyl))) +
  bb_density(alpha = 0.5) + bb_legend(aesthetic = "fill")
plot_list(a, b, c, d, ncol = 2, tag_levels = "A")

bbplot(mtcars, bb_aes(factor(cyl), mpg)) +
  bb_boxplot(col = "grey90") +
  bb_signif(comparisons = list(c("4", "6"), c("4", "8")))

bb_signif() is the one layer whose “data” is not a variable but a set of comparisons; it draws brackets above the data and extends the y range so they are not clipped. Its aesthetics are constants (col, cex, lty, …) rather than mappings, because there is no per-row value to map.

bb_violin() estimates one density per group with stats::density() and scales it to width in x units, so groups with different sample sizes stay comparable. With orientation = "horizontal", map the numeric values to x and the groups to y; bb_boxplot(orientation = "horizontal") follows the same convention. bb_coord_flip() turns a vertical violin/boxplot sideways as well. adjust and bw control the bandwidth; kernel, trim and n are passed through. The default orientation is vertical; a group needs at least two finite values with a non-zero spread, and a constant group is skipped with a warning rather than silently drawn as a flat line. In the vertical orientation, a numeric x supplies the group positions; with a factor or character x, groups sit at 1, 2, 3, … and the axis shows those numeric positions.

set.seed(7)
violin_data <- data.frame(
  group = factor(rep(c("a", "b", "c"), c(30, 25, 20))),
  value = c(rnorm(30), rnorm(25, 2), rnorm(20, 4))
)
bbplot(violin_data, bb_aes(group, value)) +
  bb_violin(fill = "steelblue", alpha = 0.5)

The horizontal form swaps the mapping rather than the drawing code:

bbplot(violin_data, bb_aes(value, group)) +
  bb_violin(orientation = "horizontal", fill = "steelblue", alpha = 0.5)

The same switch applies to the other statistical layers:

a <- bbplot(mtcars, bb_aes(mpg, factor(cyl))) +
  bb_boxplot(orientation = "horizontal")
b <- bbplot(mtcars, bb_aes(factor(cyl))) +
  bb_bar(stat = "count", orientation = "horizontal")
c <- bbplot(mtcars, bb_aes(y = mpg)) +
  bb_density(orientation = "horizontal", fill = "steelblue", alpha = 0.5)
d <- bbplot(mtcars, bb_aes(y = mpg)) +
  bb_hist(orientation = "horizontal", col = "grey80", border = "white")
plot_list(a, b, c, d, ncol = 2, tag_levels = "A")

bb_smooth() makes the fit explicit: method is "loess" or "lm", formula defaults to y ~ x (or x ~ y for a horizontal fit), method.args carries span or other fitting arguments, and se/level control the confidence band. Fits happen when the layer is added, so redrawing does not re-fit; a group with too few points or distinct x values falls back to a linear fit with a warning. The band uses fill (or the curve colour at low alpha), and the curve uses col/lty/lwd.

set.seed(8)
smooth_data <- data.frame(
  x = 1:60,
  y = sin((1:60) / 8) + rnorm(60, sd = 0.3),
  g = rep(c("a", "b"), each = 30)
)
bbplot(smooth_data, bb_aes(x = x, y = y, col = g)) +
  bb_point(pch = 19, cex = 0.6) +
  bb_smooth(method = "loess", se = TRUE, level = 0.95) +
  bb_legend(title = "group")

A horizontal smooth maps the value to x and the predictor to y:

bbplot(smooth_data, bb_aes(y, x)) +
  bb_point(pch = 19, cex = 0.6) +
  bb_smooth(orientation = "horizontal", method = "loess", se = TRUE)

Fitted lines, intervals and errors follow the same pattern: the layer computes the summary and contributes both the summary and its uncertainty to the range.

means <- mtcars |>
  group_by(cyl) |>
  summarise(x = cyl[1], y = mean(disp), sd = sd(disp), .groups = "drop")

a <- bbplot(means, bb_aes(x, y)) +
  bb_line(col = "grey50") +
  bb_point(pch = 19, cex = 1.5) +
  bb_errorbar(bb_aes(ymin = y - sd, ymax = y + sd), width = 0.8)

b <- bbplot(mtcars, bb_aes(mpg, disp, group = cyl, col = factor(cyl))) +
  bb_point() +
  bb_lm(lwd = 2, se = TRUE)

plot_list(a, b, ncol = 2, tag_levels = "A")

Bands and reference lines

Two workflows come up often enough to deserve their own layers: a band that shows an interval around a line, and reference lines that mark thresholds.

bb_ribbon() draws the band between ymin and ymax along x; with orientation = "horizontal" the band is between xmin and xmax along y. In the vertical form the layer maps x, ymin and ymax; in the horizontal form it maps y, xmin and xmax. A constant or mapped fill (or col) sets the interior, and alpha controls its transparency. Within a group the points are sorted by the centre coordinate, a group needs at least two points (smaller groups are skipped with a warning), and rows with a missing or non-finite coordinate are dropped; a missing group warns as well. The band contributes its three coordinates to the ranges, so it can extend the canvas without a separate call.

fit <- lm(disp ~ mpg, data = mtcars)
grid <- data.frame(mpg = seq(min(mtcars$mpg), max(mtcars$mpg),
                             length.out = 50))
ci <- as.data.frame(predict(fit, newdata = grid, interval = "confidence"))
ci$mpg <- grid$mpg

bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_ribbon(bb_aes(x = mpg, ymin = lwr, ymax = upr), data = ci,
            fill = "steelblue", alpha = 0.3) +
  bb_point(pch = 19, col = "grey40") +
  bb_lm(lwd = 1.5, se = FALSE, col = "firebrick")

The horizontal form puts the band between xmin and xmax along y:

bbplot(ci, bb_aes(y = mpg, xmin = lwr, xmax = upr)) +
  bb_ribbon(orientation = "horizontal", fill = "steelblue", alpha = 0.3)

With a mapped fill the band is drawn per group. The grouping rule is the same one the summary layers use: an explicit group wins, otherwise fill decides.

bands <- data.frame(
  x = rep(1:5, 2),
  lo = c(1, 2, 3, 4, 5, 2, 3, 4, 5, 6) - 0.4,
  hi = c(1, 2, 3, 4, 5, 2, 3, 4, 5, 6) + 0.4,
  g = factor(rep(c("a", "b"), each = 5))
)

bbplot(bands, bb_aes(x, fill = g)) +
  bb_ribbon(bb_aes(ymin = lo, ymax = hi), alpha = 0.3)

bb_hline() and bb_vline() mark reference positions. The intercept is either a constant or a mapping, so thresholds can come from the data; a mapped intercept can also carry col and lty per line.

thresholds <- data.frame(threshold = c(100, 300))

bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_point(pch = 19, col = "grey40") +
  bb_hline(mapping = bb_aes(yintercept = threshold), data = thresholds,
           lty = 2, col = "steelblue") +
  bb_vline(20, col = "firebrick", lty = 3)

By default a reference line does not change the axis range: a line at the edge of the data should not silently stretch the canvas. expand_limits = TRUE is the explicit opt-in when the intercept must be inside the visible range.

bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_point(pch = 19, col = "grey40") +
  bb_hline(500, col = "grey50", lty = 2, expand_limits = TRUE)

Heatmaps

bb_tile() draws one rectangle per row, positioned at the x/y coordinates and filled with the col (or fill) aesthetic. It is the one geometric layer where fill and col mean the same thing — both name the interior of the rectangle — and it is the simplest way to see a continuous mapping with and without a palette. Because each tile is independent, a scale applies per tile, and a constant fills all of them the same way.

df <- expand.grid(x = 1:8, y = 1:6)
df$value <- with(df, x * y) + rnorm(nrow(df), sd = 3)

a <- bbplot(df, bb_aes(x, y, col = value)) + bb_tile() +
  bb_title("continuous mapping")
b <- bbplot(df, bb_aes(x, y, col = value)) +
  bb_tile() +
  bb_scale_col_palette("YlOrRd") +
  bb_title("with a palette")
c <- bbplot(df, bb_aes(x, y, col = value)) +
  bb_tile() +
  bb_text(bb_aes(label = round(value)), col = "black", cex = 0.7) +
  bb_theme_expand() +
  bb_title("with text labels")
plot_list(a, b, c, ncol = 3, tag_levels = "A")

Writing your own layer

bb_layer() is the supported extension point. It takes a drawing function and runs it inside the grammar: the function receives the resolved x/y, the mapped aesthetics that were not overridden by a constant, and the fixed aesthetics you pass in ....

The contract is the same one the built-in layers follow, and it is worth spelling out because it determines what a custom layer can and cannot do:

What the layer does not get for free is the part the built-in layers implement for their own geometry: its mapped aesthetics join the shared scale and the automatic legend, but it cannot define a new scale or a custom legend key, and the coordinates it computes itself (a band, a curve, a reference line) do not widen the axes unless they are declared. Declaring them is what the second example does.

bbplot(mtcars, bb_aes(mpg, disp, col = factor(cyl))) +
  bb_layer(
    function(x, y, col, size = 1, ...) {
      graphics::points(x, y, col = col, pch = 19, cex = size)
      graphics::points(x, y, col = "black", pch = 1, cex = size)
    },
    size = 1.5,
    name = "hollow circles over points"
  ) +
  bb_legend()

A custom layer follows the rules of the grammar, and has the same limits:

low <- subset(mtcars, mpg < 20)
p_low <- bbplot(low, bb_aes(mpg, disp)) + bb_point(pch = 19)
p_low +
  bb_layer(
    function(x, y, ...) graphics::abline(h = min(y) - 50, col = "firebrick", lty = 2),
    limits = list(y = c(min(low$disp) - 50, max(low$disp)))
  )

Without limits, the same line would be drawn below the canvas and clipped.

The base graphics escape hatch

For code that is not worth wrapping, + also accepts a function, a formula or an expression of base graphics calls. They are drawn in order, after the layers added before them, and behave like any other layer with respect to graphics state and error propagation. The difference between the three forms is only when the code is evaluated: a function is called at drawing time, while a formula or a quoted expression is evaluated when it is added, in the calling environment. The formula form is convenient for one or two calls; anything that needs arguments, loops or intermediate variables is clearer as bb_layer().

These escape hatches are powerful and intentionally unvalidated. The grammar does not know what a raw base call will draw, so it cannot contribute its coordinates to the ranges, cannot tell whether it uses the data, and cannot check its aesthetics. That is the trade: full access to base graphics, no grammar guarantees. When the drawing should participate in ranges, facets or scales, wrap it in bb_layer() and declare what it needs.

bbplot(mtcars, bb_aes(mpg, disp)) +
  bb_point(pch = 19) +
  (~abline(stats::lm(disp ~ mpg, data = mtcars), col = "firebrick", lwd = 2)) +
  (~text(30, 450, label = "base graphics still work", col = "steelblue"))

Existing plotting code can become the canvas itself with as.bbplot(). The function you pass draws the background; layers, themes and labels are then added on top of it. A canvas does not carry data, so a layer added to it must be given data = explicitly — that is the one place where the usual inheritance of data has nothing to inherit:

draw_background <- function() {
  plot(mtcars$mpg, mtcars$disp, pch = 19, col = "grey75",
       xlab = "", ylab = "")
  abline(stats::lm(disp ~ mpg, data = mtcars), col = "firebrick")
}

as.bbplot(draw_background) +
  bb_lm(bb_aes(mpg, disp), data = mtcars, lwd = 2, se = FALSE,
        col = "steelblue") +
  bb_title("a base plot turned into a bbplot") +
  bb_theme_minimal()

A third-party package can add its own component type by implementing bbplot_add() for its class; the method receives the object and the plot and must return the plot.

Extending from a package

There are three levels at which code outside plotbb can join the grammar, and they differ in how much of the grammar they get:

bb_threshold <- function(value) {
  structure(list(value = value), class = "bb_threshold")
}

bbplot_add.bb_threshold <- function(object, plot) {
  plot + bb_layer(function(x, y, ...) {
    graphics::abline(h = object$value, lty = 2, col = "firebrick")
  })
}

p + bb_point(pch = 19) + bb_threshold(300)

The component is re-applied inside every facet panel, takes part in the + order and is covered by the same graphics-state restoration as the built-in components; the layer the method adds is not added a second time when a panel is rebuilt.

Statistical grouping has one rule, exposed as bb_group_values(): an explicit group mapping wins, then a discrete x, then a mapped col, then a mapped fill. Custom statistical layers should call it instead of inventing their own precedence.

bbplot(mtcars, bb_aes(factor(cyl), mpg)) +
  bb_layer(
    function(x, y, ...) {
      graphics::rect(0.5, min(y), 3.5, max(y), col = "grey95", border = NA)
      graphics::points(as.numeric(x), y, ...)
    },
    aesthetics = "fill",
    summary = TRUE,
    range = function(plot, data, mapping) {
      list(x = c(0.5, 3.5), y = range(data$mpg, na.rm = TRUE))
    }
  )

The custom drawing itself still follows the bb_layer() limits: mapped coordinates count towards the ranges, coordinates it computes itself do not unless declared with limits.

Combining complete plots

+ adds layers and components to one plot; it deliberately refuses a second complete plot. Arranging several complete plots is a different operation and is delegated to aplot::plot_list() from the same author’s aplot package (aplot has plot_list(), not plot_grid()):

p1 <- bbplot(mtcars, bb_aes(mpg, disp)) + bb_point()
p2 <- bbplot(mtcars, bb_aes(hp, wt)) + bb_point(col = "firebrick")
aplot::plot_list(p1, p2)

This is not the same as bb_facet_wrap() or bb_facet_grid(): those split one plot into panels, while aplot::plot_list() arranges separate plots. plotbb keeps the two operations separate.

A complete workflow

Putting the rules together: data, inherited mapping, a layer with its own data, constants for the annotations, then labels, a scale, a legend and a theme.

Read the chain as a sequence of decisions. bbplot() fixes the data and the default mapping, including the colour that will be scaled. The first bb_point() inherits both and only adds a shape and a size, so the points follow the colour mapping. The second bb_point() replaces the data but keeps the inherited mapping, which is why the group means are positioned correctly and drawn as a different shape without restating x and y. bb_lm() uses the same inherited mapping to fit one line per colour group. The scale then translates the mapped cyl values, and the legend names them; because both are added after the layers, this is a compact demonstration of deferred drawing. Labels and theme come last because they affect the frame rather than the data.

means <- mtcars |>
  group_by(cyl) |>
  summarise(mpg = mean(mpg), disp = mean(disp), .groups = "drop")

bbplot(mtcars, bb_aes(mpg, disp, col = factor(cyl))) +
  bb_point(pch = 19, cex = 0.8) +
  bb_point(bb_aes(mpg, disp), data = means, pch = 17, cex = 2.2) +
  bb_lm(lwd = 1.5, se = FALSE) +
  bb_scale_col_manual(c(`4` = "#1b9e77", `6` = "#d95f02", `8` = "#7570b3")) +
  bb_legend(title = "cylinders") +
  bb_labs(title = "mtcars", xlab = "miles per gallon", ylab = "displacement") +
  bb_theme_minimal()

Inspecting and saving a plot

A plot object can be inspected without drawing it. bb_build() performs the same computation the printer does — training the scales, resolving the mappings, collecting the data and computing the limits — and returns a plain list. It opens no device, changes no par() and does not touch the random seed, so it is safe in tests and in batch code that only wants to check a plot before rendering it.

built <- bb_build(
  p + bb_point(pch = 19) +
    bb_scale_col_manual(c(`4` = "#1b9e77", `6` = "#d95f02", `8` = "#7570b3"))
)
names(built)
 [1] "data"     "mapping"  "layers"   "scales"   "limits"   "panels"  
 [7] "labs"     "position" "facet"    "warnings"
built$limits
$xlim
[1]  9.46 34.84

$ylim
[1]  55.064 488.036
built$layers[[1]]$name
[1] "point layer"
built$scales$col$domain
[1] "4" "6" "8"

Faceted plots report one entry per panel, with the limits that panel would use. This is also where build and print are guaranteed to agree: both go through bb_prepare_plot(), so the limits in the build result are the limits the printer uses.

facet_built <- bb_build(
  p + bb_point() + bb_facet_wrap(~am, ncol = 2, scales = "free_y")
)
vapply(facet_built$panels, `[[`, "", "key")
[1] "0" "1"
facet_built$panels[[1]]$ylim
[1] 106.024 486.076

bb_save() writes a plot to a file. The device comes from the extension (pdf, png, jpeg/jpg, tiff/tif, svg) or from an explicit device; sizes are given with units, and raster devices take res. It closes only the device it opened and restores the caller’s active device, so it can be used inside an existing graphics session.

out <- tempfile(fileext = ".pdf")
bb_save(p + bb_point(pch = 19), out, width = 6, height = 4)
file.exists(out)
[1] TRUE
unlink(out)

An existing file is never overwritten unless overwrite = TRUE; a missing directory, a device that conflicts with the extension and invalid sizes are errors. On failure the temporary file is removed, the original error is kept, the caller’s device is restored and an existing target is left untouched (even with overwrite = TRUE).

The contract at a glance

The rules above, collected in one place. Each one is implemented once in the package, tested, and used by every layer; the sections above show it in action.

  1. A plot is an object plus components. bbplot() sets the data and the default mapping; + adds one component to one plot. Printing draws and restores the graphics state.
  2. Layers inherit data and mapping. A local mapping overrides the inherited one aesthetic by aesthetic. inherit = FALSE removes the inherited mapping but keeps the inherited data unless data is given.
  3. Layer data is first-class. It participates in the axis ranges and in bb_facet_wrap(); a panel takes the rows that carry its facet value, otherwise row-aligned data follows the panel positions, otherwise the plot warns and draws the layer unsplit.
  4. Constants win, and they remove the mapping. A constant aesthetic is not trained by a scale, is not repainted by a later one, and does not group a statistical layer. Length-n constants are row-aligned and follow the rows that survive validation.
  5. Order is drawing order. There is no other ordering; later layers cover earlier ones.
  6. Scales translate mappings at draw time, once per aesthetic. A scale is trained from the plot and every layer, shared across facets, and used by both the layers and the legend. col and fill are separate; constants never go through a scale. By default a legend describes the plot-level mapping; bb_legend(layer = ) selects one layer instead.
  7. Themes are plot state, not device state. They are applied while drawing and restored afterwards, also on error. Device-level themes are explicit and scoped.
  8. Custom layers get the grammar, not the geometry. bb_layer() inherits data and mapping, honours constants, draws in order and propagates errors. It must declare with limits (or range for a summary) the coordinates it computes itself; its mapped aesthetics use the shared scale and automatic legend, but it cannot define a new scale or legend type.
  9. The escape hatches are unvalidated. Functions, formulas and expressions draw in order and share the graphics-state contract, but they are not part of the range, scale or facet machinery.

When something goes wrong

The error messages are written to point back at a rule. A short map:

Reference

Layers

Layer Requires Notes
bb_point() x, y position = "jitter" with a reproducible seed
bb_line() x, y connects rows in data order
bb_text() x, y, optional label a constant label wins over a mapping
bb_segment() x, y, xend, yend x1/y1 accepted as aliases
bb_errorbar() x + ymin/ymax, or y + xmin/xmax orientation, width
bb_bar() x (stat = "count") or x, y orientation; horizontal identity takes x values and y categories
bb_boxplot() x, y groups by x, or by y when horizontal; orientation
bb_hist() numeric x (vertical) or numeric y (horizontal), with a discrete other axis as a group draws comparable histograms within each group; mapped colours are group-based; orientation
bb_density() numeric x (vertical) or numeric y (horizontal), with a discrete other axis as a group grouped by the other axis or by col/fill/group; orientation
bb_tile() x, y, col (or fill) heatmap tiles
bb_ribbon() x, ymin, ymax, or y, xmin, xmax for horizontal sorted by the centre; alpha, border; one band per group
bb_violin() x (group) with numeric y, or numeric x with group y when horizontal density per group; width, adjust, bw, trim; orientation
bb_smooth() x, y, optional group explicit method/formula; se, level; orientation
bb_hline() yintercept or a yintercept mapping no range change unless expand_limits = TRUE
bb_vline() xintercept or a xintercept mapping no range change unless expand_limits = TRUE
bb_lm() x, y, optional group se, level; a mapped col/fill also groups the fits
bb_signif() comparisons, or xmin/xmax/y_position extends the y range
bb_layer() a drawing function your own layer; limits declares extra range, range declares computed summary ranges

Each layer maps a defined set of aesthetics. Aesthetics outside this table can still be passed as constants (they go straight to the base function) but are not trained by a scale:

Layer Mapped aesthetics
bb_point() col, pch, cex
bb_line() col, lty
bb_text() col, label
bb_segment() col, lty
bb_errorbar() col, lty
bb_bar() col
bb_boxplot() col, lty
bb_hist() col
bb_density() col, fill
bb_tile() col, fill
bb_ribbon() col, fill
bb_violin() col, fill
bb_smooth() col, fill
bb_hline() col, lty (per line when the intercept is mapped)
bb_vline() col, lty (per line when the intercept is mapped)
bb_lm() col, lty
bb_signif() none (constant col only)
bb_layer() whatever the drawing function accepts (col, fill, pch, lty, cex, label are resolved)

Workflow: bb_build() inspects a plot without drawing it; bb_save() writes it to a device and restores the caller’s device afterwards.

Extension: bb_layer() adds a drawing function inside the grammar; bbplot_add() adds a new component class (register the S3 method in a package); bb_group_values() is the shared statistical grouping rule.

Position: bb_scale_x_continuous(), bb_scale_y_continuous(), bb_scale_x_log10(), bb_scale_y_log10(), bb_scale_x_date(), bb_scale_y_date(), bb_coord_flip().

Components: bb_grid(), bb_facet_wrap(), bb_facet_grid(), bb_legend(), bb_labs() (and bb_title(), bb_sub(), bb_xlab(), bb_ylab()), bb_theme() with the presets bb_theme_expand(), bb_theme_grey(), bb_theme_deepblue(), bb_theme_bw(), bb_theme_minimal(), and the scales bb_scale_col_palette(), bb_scale_col_manual(), bb_scale_col_gradient(), bb_scale_fill_palette(), bb_scale_fill_manual(), bb_scale_fill_gradient(), bb_scale_pch_manual(), bb_scale_lty_manual(), bb_scale_cex_manual(), bb_scale_cex_continuous().

Known limits