vplot(mtcars) |>
mark_point(x = wt, y = mpg, color = hp, size = 3)A vellumplot plot is a stack of marks. Each mark_*() appends one drawing layer to the spec, and every mark reads the same grammar: bare column names (or expressions) captured with tidy evaluation become encodings, while scalar values become constant aesthetics. color = hp maps the hp column through a scale; color = "red" paints every element red.
You can stack as many marks as you like on a panel, and scales train across all of them at once.
Points, lines, and bars
mark_point() draws markers; mark_line() and mark_step() connect points in x order; mark_rule() draws reference lines. mark_point() also takes shape (one of "circle", "square", "triangle", "diamond", "plus", "cross") and a position = "jitter" adjustment.
vplot(pressure) |>
mark_line(x = temperature, y = pressure) |>
mark_point(x = temperature, y = pressure)shape also accepts an SVG icon — a path d string (what icon sets ship) or a .svg file — drawn as a crisp vector marker. Use a literal string for a constant icon, or map shape and give [scale_shape()] one icon per level (the legend shows the icons); size scales them.
star <- "M12 2l3 7h7l-5.5 4.5 2 7-6.5-4.5-6.5 4.5 2-7L2 9h7z"
heart <- "M12 21s-7-4.35-9.5-8.5C1 9 2.5 5 6 5c2 0 3.5 1.5 4 2.5C10.5 6.5 12 5 14 5c3.5 0 5 4 3.5 7.5C19 16.65 12 21 12 21z"
d <- data.frame(x = 1:6, y = c(2, 4, 3, 5, 4, 6), g = rep(c("a", "b"), 3))
vplot(d) |>
mark_point(x = x, y = y, shape = g, color = g, size = 1.6) |>
scale_shape(values = c(a = heart, b = star))mark_bar() draws bars from a zero baseline. Give it an explicit y for heights, or omit y and it counts rows per category (the count stat). When fill is mapped, groups stack by default; switch to side-by-side with position = "dodge" or normalise to 1 with position = "fill".
For finer control, the position argument also takes a parameterised position_*() object. position_dodge2() fills each category’s band by the groups actually present (so a ragged grouping stays centred); position_nudge() offsets by a constant; position_jitter(width=, seed=) and position_jitterdodge() control scatter for overplotted categorical points.
vplot(mtcars) |>
mark_bar(x = factor(cyl), fill = factor(gear), position = position_dodge2(padding = 0.15))Areas and intervals
mark_area() fills between a y line and zero, mark_ribbon() fills between ymin and ymax, and the interval marks draw ranges: mark_errorbar() (with caps), mark_linerange() (without), and mark_segment() from (x, y) to (xend, yend).
vplot(pressure) |>
mark_area(x = temperature, y = pressure, fill = "steelblue", alpha = 0.4) |>
mark_line(x = temperature, y = pressure)mark_boxplot() summarises the raw y values per x category into a box-and-whisker (box from Q1 to Q3, median line, whiskers at 1.5 times the IQR, outliers as points).
vplot(mtcars) |>
mark_boxplot(x = factor(cyl), y = mpg)Like points and bars, these summary marks are addressable: an error bar or line range keyed with data_id/tooltip carries that identity on every segment it draws, and a boxplot keys each box by its category — so they hover, tooltip, and select as units once rendered as an interactive widget.
mark_pointrange() and mark_crossbar() are the identity summary marks: you supply y, ymin, and ymax (e.g. a model’s estimate and interval) and they draw a point-with-range or a box-with-centre-line — no aggregation.
Reference lines and function curves
mark_abline() draws a sloped reference line y = slope * x + intercept, the diagonal companion to mark_rule()’s horizontal and vertical lines. Both slope and intercept may be vectors to draw a family of lines.
vplot(mtcars) |>
mark_point(x = wt, y = mpg) |>
mark_abline(slope = -5, intercept = 37, color = "firebrick")mark_function() draws a curve y = fun(x) sampled across the panel’s x range — handy for eyeballing a theoretical distribution against the data. Overlay a normal density on a density histogram:
set.seed(1)
vplot(data.frame(z = rnorm(500))) |>
mark_histogram(x = z, y = after_stat(density)) |>
mark_function(fun = dnorm, color = "firebrick", linewidth = 1.5)Both reuse the panel’s existing scales, so add them on top of a data layer. (mark_function() holds a live function, so it does not round-trip through as_spec().)
When many observations land on the same (x, y) — common with rounded or discrete data — mark_count() collapses them to one bubble sized by the overlap count, an honest alternative to invisible overplotting:
vplot(data.frame(cyl = mtcars$cyl, gear = mtcars$gear)) |>
mark_count(x = cyl, y = gear)Tiles and bins
mark_tile() draws a rectangle at each (x, y) coloured by fill; mark_raster() is the same thing drawn as a single raster image (a fast path that needs a complete regular grid). For continuous data, mark_bin2d() and mark_hex() bin x and y into a grid and colour each cell by count.
grid <- expand.grid(x = 1:8, y = 1:8)
grid$z <- with(grid, sin(x / 2) + cos(y / 2))
vplot(grid) |>
mark_tile(x = x, y = y, fill = z) |>
scale_fill_continuous(palette = "Batlow")mark_contour() draws iso-density contour lines of a 2-D point cloud (and mark_contour_filled() fills the bands), coloured by level. See the statistical marks article for the details.
vplot(faithful) |>
mark_point(x = eruptions, y = waiting, color = "grey70") |>
mark_contour(x = eruptions, y = waiting)Text
mark_text() draws the label aesthetic as text at each (x, y); mark_label() adds a filled background behind each label so it stays legible over busy marks. size is in points, and angle can be mapped or constant.
top <- mtcars[mtcars$mpg > 30, ]
vplot(top) |>
mark_point(x = wt, y = mpg) |>
mark_text(x = wt, y = mpg, label = rownames(top), vjust = "bottom", size = 9)On a crowded scatter, labels collide. repel = TRUE moves them apart and draws a thin leader back to each point. Placement is solved by the engine in device pixels and applied as an absolute offset, so it does not drift with the data scale and is deterministic (no seed needed). Because the offset is coordinate agnostic, repel also works on faceted and polar plots — each panel is solved and its labels kept inside it.
vplot(mtcars) |>
mark_point(x = wt, y = mpg) |>
mark_text(
x = wt, y = mpg, label = rownames(mtcars),
repel = TRUE, size = 4
)mark_text_path() sets a label along a curve instead of at a point – one label per group, its glyphs following the group’s path and rotated to the local tangent. It is the direct way to label a line without a legend, or to caption a contour or arc. Glyphs follow the tangent, so a path walked right-to-left reads upside-down – traverse it in the reading direction (here sin over a rising half-period).
t <- seq(0, pi, length.out = 60)
curve <- data.frame(x = t, y = sin(t), lab = "y = sin(x)")
vplot(curve) |>
mark_text_path(x = x, y = y, label = lab, size = 9, vjust = "bottom", offset = 2)mark_series_label() is the other legend-free way to name lines: it puts each series’ name at its end (the point with the largest x), coloured to match and repelled apart. Map the same x/y/color as the lines — the label text and colour follow the series. Give the panel a little x-room and drop the now-redundant colour legend.
set.seed(1)
econ <- data.frame(
year = rep(2000:2015, 3),
value = c(
cumsum(rnorm(16, 2)), cumsum(rnorm(16, 1)), cumsum(rnorm(16, 3))
),
series = rep(c("north", "south", "east"), each = 16)
)
vplot(econ) |>
mark_line(x = year, y = value, color = series) |>
mark_series_label(x = year, y = value, color = series) |>
xlim(2000, 2018) |>
guides(color = "none")mark_outlier_label() labels only the points that stand out — it keeps the rows whose y is an outlier (Tukey’s IQR rule, or method = "sd") and labels just those, so a busy scatter names its extremes without a wall of text. Map a label to name each outlier; with a color/fill mapped, outliers are found within each group.
vplot(mtcars) |>
mark_point(x = wt, y = mpg) |>
mark_outlier_label(x = wt, y = mpg, label = rownames(mtcars))Images
mark_image() puts a bitmap at each (x, y) in place of a marker: a flag or a company logo. src is a column of file paths (one image per datum) or a single path reused at every point. size sets the height in millimetres, and the width follows each image’s own aspect ratio, so nothing stretches.
badge <- function(text, fill) {
path <- tempfile(fileext = ".png")
magick::image_blank(120, 120, color = fill) |>
magick::image_annotate(text, size = 64, gravity = "center", color = "white") |>
magick::image_write(path)
path
}
d <- data.frame(
x = 1:3,
y = c(2, 3, 1),
logo = c(badge("A", "tomato"), badge("B", "steelblue"), badge("C", "seagreen"))
)
vplot(d) |>
mark_image(x = x, y = y, src = logo, size = 14)Reading images needs the magick package (a suggested dependency), which decodes PNG, JPEG, SVG, and more. Because size is in millimetres rather than data units, images keep their physical size as the panel resizes.
Pie and donut
mark_pie() and mark_donut() are the part-of-whole shortcuts. Each value becomes a wedge; fill colours the slices. Under the hood they are a stacked bar projected through coord_polar(), which they set for you.
parts <- data.frame(part = c("a", "b", "c", "d"), n = c(3, 5, 2, 4))
vplot(parts) |>
mark_donut(value = n, fill = part, inner_radius = 0.6)For polar plots generally, coord_radial() extends coord_polar() with a central hole (inner_radius) and a partial start–end arc — e.g. a semicircular coxcomb:
vplot(mtcars) |>
mark_bar(x = factor(cyl), fill = factor(cyl)) |>
coord_radial(theta = "x", start = -pi / 2, end = pi / 2, inner_radius = 0.2)Layering is the point
Because scales train across every layer, mixing marks on one panel works. Here a point cloud and a fitted line share the same trained x and y axes.
vplot(mtcars) |>
mark_point(x = wt, y = mpg, color = factor(cyl)) |>
mark_smooth(x = wt, y = mpg, method = "lm")From here:
- Scales and guides for the mapping from data to colour, size, and axes.
- Statistical marks for histograms, densities, and smooths in depth.
-
Spatial and networks for
mark_sf()maps andvgraph()node-link diagrams.
