Skip to contents

Most marks draw the data as given. A statistical mark computes something from the data first, then draws the result. vellumplot runs that transform as a stage of the compiler (see The compiler), so a histogram is a mark that bins the data as part of being drawn, rather than something you bin by hand.

Histograms and densities

mark_histogram() bins a continuous x and draws the per-bin counts as bars. Control the resolution with bins.

vplot(faithful) |>
  mark_histogram(x = waiting, bins = 25)
A bar chart. It plots count (vertical axis) against waiting (horizontal axis). Based on 272 observations.01020305060708090countwaiting

mark_density() draws a smooth kernel-density estimate of x as a filled curve; adjust scales the bandwidth. Densities are on a comparable vertical scale, so mapping fill to a group and overlaying works well.

vplot(penguins) |>
  mark_density(x = bill_len, fill = species, alpha = 0.4)
A area chart. It shows bill_len on the horizontal axis, where colour shows species. Based on 344 observations.0.0000.0250.0500.0750.1000.12530405060ybill_lenspeciesAdelieChinstrapGentoo

Marginal distributions

A scatter shows the joint distribution of two variables but hides each one on its own. add_marginal() puts them back: it draws a distribution of x along the top edge and of y along the right edge, each on the same scale as the panel so it lines up with the points. This is the vellumplot counterpart of ggExtra::ggMarginal().

vplot(faithful) |>
  mark_point(x = eruptions, y = waiting) |>
  add_marginal()
A scatter plot. It plots waiting (vertical axis) against eruptions (horizontal axis). Based on 272 observations.50607080902345waitingeruptions

Unlike the other marks in this article, add_marginal() is a plot modifier rather than a layer, closer to facet_wrap() than to mark_density(). It takes no encoding of its own; it reads x and y from the first plain layer (here the mark_point()) and computes the margins from those. type = "histogram" bins the values instead of smoothing them, and sides picks which edges to draw.

vplot(faithful) |>
  mark_point(x = eruptions, y = waiting) |>
  add_marginal(type = "histogram", sides = "t", bins = 20)
A scatter plot. It plots waiting (vertical axis) against eruptions (horizontal axis). Based on 272 observations.50607080902345waitingeruptions

When the scatter maps a discrete color or fill, group = TRUE splits each margin the same way, so a per-group density sits above its points in the matching colour. The scatter’s legend already names the groups, so none is added.

vplot(penguins) |>
  mark_point(x = bill_len, y = bill_dep, color = species) |>
  add_marginal(group = TRUE)
A scatter plot. It plots bill_dep (vertical axis) against bill_len (horizontal axis), where colour shows species. Based on 344 observations.141618204050bill_depbill_lenspeciesAdelieChinstrapGentoo

Margins share the panel’s scales and reserve space around a single panel, so this version does not combine with facets, a flipped or polar coordinate system, or a fixed aspect ratio.

Per-group summaries

mark_summary() aggregates y within each x category using fun (the mean by default) and draws the result. It is the quick way to add group means over raw data.

vplot(mtcars) |>
  mark_point(x = factor(cyl), y = mpg, color = "grey60") |>
  mark_summary(x = factor(cyl), y = mpg, fun = median)
A scatter plot. It plots mpg (vertical axis) against factor(cyl) (horizontal axis). Based on 32 observations.15202530468mpgfactor(cyl)

Significance brackets

mark_signif() runs a pairwise test between x groups and draws a bracket with its p-value over each comparison — the ggsignif / ggpubr idiom. Add it on top of a boxplot or violin; the brackets stack above the data and the y-axis grows to fit. comparisons is a list of the group pairs to test (default: each adjacent pair); method is "wilcox.test" or "t.test"; label is a p-value or stars.

set.seed(1)
d <- data.frame(
  grp = rep(c("ctrl", "low", "high"), each = 40),
  y = c(rnorm(40), rnorm(40, 0.4), rnorm(40, 1.2))
)
vplot(d) |>
  mark_boxplot(x = grp, y = y, fill = grp) |>
  mark_signif(
    x = grp, y = y,
    comparisons = list(c("ctrl", "low"), c("ctrl", "high")),
    label = "stars"
  )
A plot combining box plot and signif plot. It plots y (vertical axis) against grp (horizontal axis), where colour shows grp. Based on 120 observations.ns****-2024ctrlhighlowygrpgrpctrlhighlow

Smooths

mark_smooth() fits a model of y on x (per group) and draws the fitted line with a confidence ribbon when se = TRUE. It layers naturally over the raw points.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  mark_smooth(x = wt, y = mpg, se = TRUE, level = 0.95)
A plot combining scatter plot and smoothed-trend plot. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.1015202530352345mpgwt

The method chooses the fit: "lm" (linear), "loess" (local regression, with span controlling the neighbourhood), "glm" (a family via method.args, e.g. logistic), "gam" (a penalised smooth, needs ), and "rq" (quantile regression, needs ). The default "auto" picks loess for small groups and gam for large ones.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  mark_smooth(x = wt, y = mpg, method = "loess", span = 0.9)
A plot combining scatter plot and smoothed-trend plot. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.1015202530352345mpgwt

Group regions: ellipses and hulls

mark_ellipse() and mark_hull() enclose a set of points in a single region — a way to read group separation on a scatter without drawing every point’s membership. When a color or fill is mapped, each group gets its own region.

mark_ellipse() draws a covariance ellipse (a robust t distribution by default, or "norm" / "euclid"), following ggplot2’s stat_ellipse():

vplot(iris) |>
  mark_point(x = Sepal.Length, y = Sepal.Width, color = Species) |>
  mark_ellipse(x = Sepal.Length, y = Sepal.Width, color = Species)
A plot combining scatter plot and ellipse plot. It plots Sepal.Width (vertical axis) against Sepal.Length (horizontal axis), where colour shows Species. Based on 150 observations.2.02.53.03.54.0567Sepal.WidthSepal.LengthSpeciessetosaversicolorvirginica

mark_hull() draws the convex hull instead. Both are unfilled boundaries by default (matching ggplot2); map or set a fill to shade them:

vplot(iris) |>
  mark_point(x = Sepal.Length, y = Sepal.Width, color = Species) |>
  mark_hull(x = Sepal.Length, y = Sepal.Width, fill = Species, alpha = 0.2)
A plot combining scatter plot and hull plot. It plots Sepal.Width (vertical axis) against Sepal.Length (horizontal axis), where colour shows Species. Based on 150 observations.2.02.53.03.54.0567Sepal.WidthSepal.LengthSpeciessetosaversicolorvirginica

Rolling and cumulative windows

mark_line(window = ) transforms a line’s y before drawing it — a rolling statistic, a running total, or a lag/lead — computed per group over rows ordered by x. Pass an op name or a list(op, k, ...). Here a 7-point trailing mean rides over the raw series:

set.seed(1)
ts <- data.frame(day = 1:120, value = cumsum(rnorm(120)))
vplot(ts) |>
  mark_line(x = day, y = value, color = "grey70") |>
  mark_line(x = day, y = value, window = list(op = "mean", k = 7))
A line chart. It plots value (vertical axis) against day (horizontal axis). Based on 120 observations.0510306090120valueday

The ops cover rolling mean/sum/median/min/max (with k, align, and partial), running cumsum/cummean/cummax/cummin, lag/lead, and rank.

Distributions

Several marks summarise how a single variable is distributed. mark_ecdf() draws the empirical cumulative distribution of x as a step, which is a scale-free way to compare groups without choosing a bandwidth.

vplot(penguins) |>
  mark_ecdf(x = bill_len, color = species)
A step chart. It shows bill_len on the horizontal axis, where colour shows species. Based on 344 observations.0.250.500.751.004050ybill_lenspeciesAdelieChinstrapGentoo

mark_qq() plots the sorted sample against the quantiles of a reference distribution (normal by default); mark_qq_line() adds the reference line through the quartiles. Points on the line mean the sample matches the reference.

vplot(mtcars) |>
  mark_qq(sample = mpg) |>
  mark_qq_line(sample = mpg)
A plot combining scatter plot and line chart. Based on 32 observations.1015202530-2-1012yx

mark_rug() adds marginal ticks at each observation, a compact companion to a scatter or density. sides picks the edges ("b", "l", "t", "r").

vplot(faithful) |>
  mark_point(x = waiting, y = eruptions) |>
  mark_rug()
A plot combining scatter plot and rug plot. It plots eruptions (vertical axis) against waiting (horizontal axis). Based on 272 observations.23455060708090eruptionswaiting

Density shapes

mark_violin() draws a mirrored kernel density of y for each categorical x, a boxplot’s silhouette that shows the full shape of each group.

vplot(penguins) |>
  mark_violin(x = species, y = bill_len, fill = species)
A violin plot. It plots bill_len (vertical axis) against species (horizontal axis), where colour shows species. Based on 344 observations.4050AdelieChinstrapGentoobill_lenspeciesspeciesAdelieChinstrapGentoo

mark_ridgeline() turns that on its side: a density of x per categorical y, with the ridges overlapping so many groups fit in little vertical space. Use the height argument to tune how much they overlap.

vplot(penguins) |>
  mark_ridgeline(x = bill_len, y = species, fill = species)
A ridgeline plot. It plots species (vertical axis) against bill_len (horizontal axis), where colour shows species. Based on 344 observations.AdelieChinstrapGentoo4050speciesbill_lenspeciesAdelieChinstrapGentoo

mark_dotplot() bins x and stacks one dot per observation, so the height of each stack is a count you can read dot by dot.

vplot(faithful) |>
  mark_dotplot(x = waiting)
A scatter plot. It shows waiting on the horizontal axis. Based on 272 observations.5101520255060708090ywaiting

Uncertainty: half-eyes and intervals

When each x category has many draws — posterior samples, a bootstrap — the -style marks show the distribution’s shape and its summary intervals together. mark_halfeye() draws a one-sided density slab with a point-interval at its base (the median, a thick inner interval, a thin outer one); mark_interval() is the point-interval alone. Set the interval probabilities with .width.

set.seed(1)
draws <- data.frame(
  model = rep(c("prior", "posterior", "pooled"), each = 800),
  estimate = rnorm(2400, rep(c(0, 1.5, 1), each = 800), rep(c(1, 0.5, 0.7), each = 800))
)
vplot(draws) |>
  mark_halfeye(x = model, y = estimate, .width = c(0.66, 0.95))
A halfeye plot. It plots estimate (vertical axis) against model (horizontal axis). Based on 2400 observations.-202pooledposteriorpriorestimatemodel

Raincloud plots

A raincloud pairs the density “cloud” with the raw observations as “rain” below it — the shape and every data point at once. mark_raincloud() composes it in one call (a mark_halfeye() plus mark_point() with sina spread), and any color/fill flows to both:

vplot(penguins) |>
  mark_raincloud(x = species, y = bill_len, color = species)
A plot combining halfeye plot and scatter plot. It plots bill_len (vertical axis) against species (horizontal axis), where colour shows species. Based on 344 observations.4050AdelieChinstrapGentoobill_lenspeciesspeciesAdelieChinstrapGentoo

The rain uses position_sina(), which spreads each group’s points along x in proportion to the local density — so the point cloud echoes the violin’s shape rather than a flat jitter. Reach for it directly on a plain scatter of a categorical x when you want to see every observation:

vplot(penguins) |>
  mark_point(x = species, y = bill_len, color = species, position = "sina")
A scatter plot. It plots bill_len (vertical axis) against species (horizontal axis), where colour shows species. Based on 344 observations.4050AdelieChinstrapGentoobill_lenspeciesspeciesAdelieChinstrapGentoo

2-D density contours

mark_contour() estimates the 2-D density of a point cloud and draws its iso-density contour lines; mark_contour_filled() fills the bands between them. Both are coloured by level automatically. They read best over the points they summarise:

vplot(faithful) |>
  mark_point(x = eruptions, y = waiting, color = "grey70") |>
  mark_contour(x = eruptions, y = waiting)
A plot combining scatter plot and contour plot. It plots waiting (vertical axis) against eruptions (horizontal axis), where colour shows level. Based on 272 observations.50607080902345waitingeruptionslevel0.0050.0100.0150.020
vplot(faithful) |>
  mark_contour_filled(x = eruptions, y = waiting)
A contour_filled plot. It plots waiting (vertical axis) against eruptions (horizontal axis), where colour shows level. Based on 272 observations.50607080902345waitingeruptionslevel0.0050.0100.0150.020

The density estimate uses MASS::kde2d(); tune the levels with bins, binwidth, or explicit breaks. To contour a surface you already have (a z value on a regular x/y grid) rather than a point density, map z:

grid <- expand.grid(x = seq(-3, 3, 0.1), y = seq(-3, 3, 0.1))
grid$z <- with(grid, dnorm(x) * dnorm(y))
vplot(grid) |> mark_contour(x = x, y = y, z = z)
A contour plot. It plots y (vertical axis) against x (horizontal axis), where colour shows level. Based on 3721 observations.-2-1012-2-1012yxlevel0.050.10

Contour tracing is done by the engine (vellum::vl_contour()), so it needs no extra package; a density field additionally uses MASS for the estimate.

Reaching computed variables with after_stat

A statistical mark produces new variables that were not in your data: a histogram computes a count and a density, for example. after_stat() lets an encoding refer to one of those computed variables instead of a data column. The classic use is a density-scaled histogram.

vplot(faithful) |>
  mark_histogram(x = waiting, bins = 25, fill = after_stat(density))
A bar chart. It plots count (vertical axis) against waiting (horizontal axis), where colour shows density. Based on 272 observations.01020305060708090countwaitingdensity0.020.040.06

Because the aesthetic is now driven by a computed value, its scale trains on that value like any other, and you get the matching legend.

Millions of points with mark_datashade

When there are too many points to draw one marker each (overplotted, up to millions), individual markers become uninformative and slow to draw. mark_datashade() bins the points into a canvas-sized grid in a single pass and colours each cell by density, drawing one raster. Its cost is decoupled from the number of points.

n <- 5e5
big <- data.frame(
  x = rnorm(n),
  y = rnorm(n) + rep(c(-1, 1), each = n / 2)
)
vplot(big) |>
  mark_datashade(x = y, y = x, how = "eq_hist")
A datashade plot. It plots x (vertical axis) against y (horizontal axis). Based on 500000 observations.-2.50.02.5-5.0-2.50.02.55.0xy

Here how = "eq_hist" uses histogram equalisation so both dense and sparse regions stay visible; the grid resolution is set by width and height.

Map a discrete color (or fill) aesthetic to shade categorically (datashader’s count_cat): each category is aggregated separately and every cell is coloured by the count-weighted mix of the categories it holds, opacity by density — with a colour legend.

big$g <- sample(c("a", "b"), n, replace = TRUE)
vplot(big) |>
  mark_datashade(x = y, y = x, color = g)
A datashade plot. It plots x (vertical axis) against y (horizontal axis), where colour shows g. Based on 500000 observations.-2.50.02.5-5.0-2.50.02.55.0xygab

mark_point() also has an auto = TRUE switch that falls back to a datashaded raster automatically when a layer has very many rows, so you can keep writing mark_point() and let vellumplot choose the representation.