p <- bbplot(mtcars, bb_aes(mpg, disp))
p + bb_point(pch = 19)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.
A plot is a plot object plus a series of components added with +:
bbplot(data, mapping) creates the object: the data every layer can see and the mapping every layer can inherit.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.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)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)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.
bb_point(bb_aes(col = factor(am))) keeps the inherited x and y and replaces only col.bb_facet_wrap() panel takes the rows with that panel’s value; row-aligned data with the same number of rows as the plot data follows the panel row positions instead.inherit = FALSE turns the inheritance off: the layer then uses only its own mapping. Its data still comes from the plot unless you pass data.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.
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.
bb_point(bb_aes(col = factor(cyl))) — mapped: the colour follows the scale and the legend.bb_point(col = "tomato") — constant: the whole layer is one colour. It is not trained by a scale and is not repainted by one, even if the scale or the legend is added later in the + chain.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.
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")+ 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()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.
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")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)
})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 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:
+ chain, because all the layers have already been seen when it is trained;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.
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()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")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")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)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()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)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")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)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")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:
bbplot() unless the layer brings its own, and inherit = FALSE has the same meaning as elsewhere;+ order and receives the same rows that survived validation, with length-n constants already aligned to them;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:
data and mapping, and accepts its own plus inherit;+ order, and its errors propagate unchanged;limits; mapped coordinates always count.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.
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.
There are three levels at which code outside plotbb can join the grammar, and they differ in how much of the grammar they get:
+ is the raw base-graphics escape hatch. It draws in order and shares the graphics-state contract, but it is not validated, does not contribute to ranges and is not part of the scale or facet machinery. Use it for one-off additions.bb_layer() is the stable custom-layer entry point. It inherits data and mapping, honours constants, draws at print time in + order, follows the facets, contributes its mapped x/y (and any declared limits) to the ranges, and propagates errors unchanged. A statistical layer can also declare aesthetics = "fill", summary = TRUE and a range = function(plot, data, mapping) returning list(x = ..., y = ...), so it participates in ranges without editing package internals. It cannot define a new scale or legend type; its mapped aesthetics use the shared scale and automatic legend.bbplot_add() lets a package define a new kind of component. A method receives the object and the plot, returns the modified plot, and should describe what to add rather than draw it — typically by calling the public bb_layer(). It must not draw directly, change the device or rely on internal helpers such as build_layer(); those are not part of the contract. Functions, formulas and expressions are fine as an escape hatch, but a package should register its component method with S3method(bbplot_add, <class>) in NAMESPACE.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.
+ 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.
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()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 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.
bbplot() sets the data and the default mapping; + adds one component to one plot. Printing draws and restores the graphics state.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.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.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.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.The error messages are written to point back at a rule. A short map:
requires data: supply it in bbplot() or through the layer's data argument — the plot has no data to inherit, which is normal for a layer added to an as.bbplot() canvas. Pass data = to that layer.has no data to draw — the layer’s mapping resolves to nothing. The usual causes are a missing x/y mapping, or inherit = FALSE without restating them.has length N, but M row(s) were mapped — a constant or mapping does not match the number of rows. The rule is length 1 or one value per row; there is no silent recycling.has no finite coordinates to draw — every row is NA or infinite after filtering. Dropping invalid rows is a rule, not an accident, so a layer with nothing left says so.fill is not an aesthetic of this layer — use col. fill only exists where the drawing primitive has an interior separate from its border.does not carry the facet variable ... drawn in every panel — a warning, not an error: give the layer data the facet column (or the same row count) if it should be split.does not combine two complete plots — + is not a layout operator; use aplot::plot_list().limits = list(x = ..., y = ...).| 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().
col and fill have separate scales. A fill mapping without a fill scale uses the default fill mapping; it does not borrow the col scale.lty applies to the line-like layers (bb_line(), bb_segment(), bb_errorbar(), bb_lm() and bb_boxplot()). Layers without a line style (points, tiles, bars) take lty as a constant only.free_colour = TRUE opts into per-panel col/fill domains, requires an explicit colour scale and cannot be combined with an outside legend; without an explicit scale the default colour mapping is value-based and shared across panels.x/y values and its data follows the facets. Coordinates it computes itself do not widen the axes unless declared with limits or range; its mapped aesthetics use the shared scale and automatic legend, but it cannot define a new scale or legend type.+ combines components of one plot; for several plots use aplot::plot_list().bb_violin() and bb_boxplot() place groups at 1, 2, 3, … and draw numeric axis labels there; the factor labels are not written under the positions.bb_smooth() requires the formula response to be y; horizontal fits are written x ~ y and converted internally. Transform the data or mapping rather than changing the response, so the band stays on the drawn scale.bb_lm() groups its fits by an explicit group, then a discrete x, then col, then fill. A constant col suppresses that grouping; use inherit = FALSE with an explicit x/y mapping for one overall fit on a plot whose points are coloured.col/lty on bb_line() applies one value to the whole polyline; a polyline cannot change colour halfway. Add a group mapping to draw one line per group.bb_coord_flip() flips point/line/text/segment/tile/custom layers, violin/boxplot/bar/errorbar/ribbon/density/histogram/smooth (toggling their orientation) and hline/vline; bb_lm() and bb_signif() are not flipped.Date column is drawn on a numeric axis unless bb_scale_x_date() or bb_scale_y_date() is added; the positional date scale supplies the date axis and its format.bb_bar(orientation = "horizontal") uses categories on x for counts and numeric values on x / categories on y for identity bars; horizontal identity bars do not aggregate duplicate categories.