| Title: | Low-Level Graphics with Device-Independent Layout and Queryable Scenes |
|---|---|
| Description: | A grid-like low-level graphics system for R. Text metrics and layout are resolved without a graphics device, so one solved scene renders to PNG, SVG, or PDF with the same geometry, and the finished scene can report that geometry back element by element (a data key and a resolved device-pixel box) or be queried by point. The scene graph, unit/layout engine, and rendering run in a Rust backend. |
| Authors: | David Schoch [aut, cre] |
| Maintainer: | David Schoch <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.7.0.9000 |
| Built: | 2026-10-10 16:36:18 UTC |
| Source: | https://github.com/r-vellum/vellum |
Wrap a grob (or list of grobs) as a mask for vl_viewport(mask = ...). The mask
content is rendered to an isolated layer; its coverage then modulates the
visibility of the viewport's contents.
as_mask(grob, type = c("alpha", "luminance"))as_mask(grob, type = c("alpha", "luminance"))
grob |
A grob, or a list of grobs, drawn in the masked viewport's coordinate system. |
type |
|
A vellum_mask object.
as_mask(circle_grob(r = 0.4, gp = vl_gpar(fill = "white", col = NA)))as_mask(circle_grob(r = 0.4, gp = vl_gpar(fill = "white", col = NA)))
as_scene_spec() turns a rendered-ready scene into a plain nested list of R
vectors, and from_scene_spec() rebuilds the scene from it. Together they
make a compiled scene a value you can store or send, rather than something
that only exists inside the session that built it.
as_scene_spec(scene) from_scene_spec(spec)as_scene_spec(scene) from_scene_spec(spec)
scene |
A |
spec |
A list from |
This is one level below vellumplot's plot spec. A plot spec is portable and
re-renderable at any size; a scene spec is the resolved artifact — the grobs,
units and viewports as they will actually be drawn. They compose: keep the
plot spec to re-render, keep the scene spec to reproduce exactly this scene.
The conversion is generic over the S7 property model rather than written per grob type, so a grob added to vellum later serializes with no change here.
as_scene_spec(): a nested list, with a version element.
from_scene_spec(): a vellum_scene.
scene_write(), scene_diff(), scene_hash()
s <- vl_scene(3, 2) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "tomato", col = NA))) spec <- as_scene_spec(s) spec$version identical(as_scene_spec(from_scene_spec(spec)), spec)s <- vl_scene(3, 2) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "tomato", col = NA))) spec <- as_scene_spec(s) spec$version identical(as_scene_spec(from_scene_spec(spec)), spec)
as_vellum() converts a grid grob tree — or a ggplot2 plot — into a
vl_scene() by letting an offscreen grid device resolve all coordinates to
absolute units, then emitting vellum grobs. render_grid() does that and
writes the result. This is the interop path (grid-based graphics rendered by
vellum's deterministic backend); a native graphics device is future work.
as_vellum(x, width = 7, height = 7, dpi = 96, bg = "white") render_grid( x, path, width = 7, height = 7, dpi = 96, bg = "white", text = c("native", "outline") )as_vellum(x, width = 7, height = 7, dpi = 96, bg = "white") render_grid( x, path, width = 7, height = 7, dpi = 96, bg = "white", text = c("native", "outline") )
x |
A grid grob/gTree/gtable, or a ggplot object. |
width, height
|
Page size in inches. |
dpi, bg
|
As in |
path |
Output file ( |
text |
Passed to |
as_vellum(): a vellum_scene. render_grid(): path, invisibly.
## Not run: library(ggplot2) p <- ggplot(mtcars, aes(wt, mpg)) + geom_point() render_grid(p, "plot.png", width = 6, height = 4) ## End(Not run)## Not run: library(ggplot2) p <- ggplot(mtcars, aes(wt, mpg)) + geom_point() render_grid(p, "plot.png", width = 6, height = 4) ## End(Not run)
The extensible seam a higher-level package (e.g. a grammar layer) implements to
compile its own plot object into a vl_scene(). render() coerces its input
through this generic, so render(x, path) works for any x that has an
as_vellum_scene() method. An identity method for vellum_scene is provided.
as_vellum_scene(x, ...)as_vellum_scene(x, ...)
x |
An object to coerce: a |
... |
Passed on to methods. |
This is the stable compiler-backend entry point: downstream packages should
target as_vellum_scene() (and the exported grob/viewport/unit constructors)
rather than vellum's internal compile() / .scene_to_backend() helpers.
A vellum_scene.
sc <- vl_scene() identical(as_vellum_scene(sc), sc) # the identity method returns its inputsc <- vl_scene() identical(as_vellum_scene(sc), sc) # the identity method returns its input
For data beyond the point where drawing one marker each is practical
(overplotted, millions of points), the fast and overplotting-honest approach
is not to draw markers faster but to not draw markers at all: bin the points
into a canvas-sized grid in one pass, then colour each cell by its density. This
is what makes datashader fast — aggregation decouples
cost from both point count and overplotting. datashade() returns a single
raster_grob() you draw like any other grob.
datashade( x, y, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, category = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL )datashade( x, y, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, category = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL )
x, y
|
Point coordinates (plain numerics, in data space). |
weight |
Optional per-point weight; cells accumulate the summed weight
instead of a plain count. |
width, height
|
Aggregation grid size in cells (= output raster pixels). |
xlim, ylim
|
Data range to bin over; default the finite range of |
category |
Optional per-point category (factor or vector) selecting
categorical ( |
colors |
For density shading, two or more colours forming the low-to-high
ramp. For categorical shading ( |
how |
Density-to-colour mapping: |
span |
Optional |
clip |
Optional percentile pair in |
spread |
Optional post-aggregation spreading, applied to the shaded
raster to keep sparse output visible (see |
interpolate |
Passed to |
name, vp, id, role
|
Passed to |
The points are binned over xlim x ylim into a width x height grid, so to
line the image up with data axes draw it inside a vl_viewport() whose xscale /
yscale match xlim / ylim (it fills the viewport, npc 0..1). For crisp
bins make width/height match the viewport's pixel size and keep
interpolate = FALSE.
A grob (a raster), drawable with draw().
count_cat)Pass category (a factor or vector, one value per point) to shade by category
rather than plain density: each category is aggregated into its own count grid in
the same single pass, and each cell is coloured by the count-weighted average
of the category hues it contains, with opacity driven by the cell's total density
(via how). This reveals which category dominates where and where categories
mix, without overplotting bias. When category is set, colors is a per-category
hue vector (named by level, or one colour per level in level order) instead of a
low-to-high ramp.
datashade_lines() and datashade_segments() for the line/segment
(dense-timeseries, network-edge) counterparts, and dynspread()/spread().
set.seed(1) n <- 1e6 x <- rnorm(n); y <- x * 0.5 + rnorm(n) g <- datashade(x, y, width = 400, height = 300) s <- vl_scene(6, 4.5) |> push(vl_viewport(xscale = range(x), yscale = range(y))) |> draw(g) # Categorical: colour each cell by which group dominates it grp <- sample(c("a", "b"), n, replace = TRUE) gc <- datashade(x, y, category = grp, colors = c(a = "#e41a1c", b = "#377eb8"))set.seed(1) n <- 1e6 x <- rnorm(n); y <- x * 0.5 + rnorm(n) g <- datashade(x, y, width = 400, height = 300) s <- vl_scene(6, 4.5) |> push(vl_viewport(xscale = range(x), yscale = range(y))) |> draw(g) # Categorical: colour each cell by which group dominates it grp <- sample(c("a", "b"), n, replace = TRUE) gc <- datashade(x, y, category = grp, colors = c(a = "#e41a1c", b = "#377eb8"))
The line/segment analogue of datashade(). Past a few hundred thousand line
vertices — a dense stack of timeseries, or the edges of a large graph — drawing
each line as a vector primitive overplots into a solid mass and balloons the
output. datashade_lines() and datashade_segments() instead rasterise the
lines into a canvas-sized grid in one pass: each cell accumulates the
(anti-aliased) coverage of the lines crossing it, so overlapping lines add and
the grid records true line density. The grid is shaded exactly like
datashade() (colors/how/span/clip) and returned as a single
raster_grob().
datashade_lines( x, y, group = NULL, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL ) datashade_segments( x0, y0, x1, y1, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL )datashade_lines( x, y, group = NULL, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL ) datashade_segments( x0, y0, x1, y1, weight = NULL, width = 600L, height = 400L, xlim = NULL, ylim = NULL, colors = c("#deebf7", "#08306b"), how = c("eq_hist", "log", "cbrt", "linear"), span = NULL, clip = NULL, spread = NULL, interpolate = FALSE, name = NULL, vp = NULL, id = NULL, role = NULL )
x, y
|
For |
group |
For |
weight |
Optional per-line weight (per start-vertex for
|
width, height
|
Aggregation grid size in cells (= output raster pixels). |
xlim, ylim
|
Data range to bin over; default the finite range of |
colors |
For density shading, two or more colours forming the low-to-high
ramp. For categorical shading ( |
how |
Density-to-colour mapping: |
span |
Optional |
clip |
Optional percentile pair in |
spread |
Optional post-aggregation spreading, applied to the shaded
raster to keep sparse output visible (see |
interpolate |
Passed to |
name, vp, id, role
|
Passed to |
x0, y0, x1, y1
|
For |
datashade_lines() takes a connected polyline: a segment is drawn between
each consecutive (x, y). Pass group to pack several series into one call —
the line breaks wherever the group changes; an NA in x/y also breaks it.
This is the dense-timeseries path.
datashade_segments() takes independent segments (x0, y0) -> (x1, y1),
one per element. This is the network-edge / mark_segment path.
Line coverage is anti-aliased (a Wu accumulator) and summed, so a line deposits
roughly weight per cell it spans and dense bundles brighten honestly rather
than saturating. As with datashade(), align the raster to data axes by drawing
it in a vl_viewport() whose xscale/yscale match xlim/ylim.
A grob (a raster), drawable with draw().
datashade() for points; dynspread()/spread() for keeping thin
lines visible.
set.seed(1) # Dense timeseries: 400 random walks of 500 steps, packed into one raster. k <- 400; m <- 500 walks <- apply(matrix(rnorm(k * m), m, k), 2, cumsum) t <- rep(seq_len(m), k) g <- datashade_lines(t, as.vector(walks), group = rep(seq_len(k), each = m), width = 400, height = 300) # Network edges: random segments shaded by edge density. n <- 5000 e <- datashade_segments(rnorm(n), rnorm(n), rnorm(n), rnorm(n))set.seed(1) # Dense timeseries: 400 random walks of 500 steps, packed into one raster. k <- 400; m <- 500 walks <- apply(matrix(rnorm(k * m), m, k), 2, cumsum) t <- rep(seq_len(m), k) g <- datashade_lines(t, as.vector(walks), group = rep(seq_len(k), each = m), width = 400, height = 300) # Network edges: random segments shaded by edge density. n <- 5000 e <- datashade_segments(rnorm(n), rnorm(n), rnorm(n), rnorm(n))
Attach (or replace) an accessible name (title) and long description
(desc, the alt text) on an existing scene. The SVG backend then emits
role="img" + <title>/<desc>, and the PDF backend tags the page as a
Figure with the description as Alt text — meeting WCAG 1.1.1 (text
alternative). Equivalent to passing title/desc to vl_scene().
describe(scene, title = NULL, desc = NULL)describe(scene, title = NULL, desc = NULL)
scene |
A |
title |
An accessible name (short), or |
desc |
A long description / alt text, or |
The scene, with the accessibility fields set (a new value).
vl_scene(2, 2) |> draw(points_grob(c(0.3, 0.7), 0.5, gp = vl_gpar(fill = "red"))) |> describe(title = "Two red dots", desc = "Two red points on a white field.")vl_scene(2, 2) |> draw(points_grob(c(0.3, 0.7), 0.5, gp = vl_gpar(fill = "red"))) |> describe(title = "Two red dots", desc = "Two red points on a white field.")
display() re-renders scene to fill the current graphics device and draws it
there. Interactively this is the RStudio / Positron Plots pane; inside a
knitr / Quarto chunk it becomes the chunk's figure (it draws to the chunk's
device). This is the seam any package built on vellum can call to show output
instead of writing a file: scene is coerced via as_vellum_scene(), so it
also accepts e.g. a grammar's plot spec.
display(scene, ...)display(scene, ...)
scene |
A |
... |
Unused. |
To fill the window (no letterbox margins, like ggplot2) the scene is re-rendered
at the device's size and pixel density, so its relative (npc/native/layout)
content reflows to the window and absolute (mm/in/pt) content keeps its
physical size. It draws through a grid grob that re-rasterizes on every draw, so
resizing the Plots pane re-renders the scene crisply at the new size (round
markers stay round) rather than stretching one bitmap. Use render() to write
the scene at its authored width/height. Auto-printing a scene at the console
(or calling plot() on it) displays it.
Inside a knitr / Quarto chunk the chunk's dpi option wins, so
knitr::opts_chunk$set(dpi = 200) yields a genuine 200-dpi figure even on
knitr's default dev = "png" device (which misreports its pixel density);
outside knitting the scene's authored vl_scene() dpi is honored unless the
live device reports a trustworthy higher density (e.g. a resized Plots pane).
The (coerced) scene, invisibly.
## Not run: vl_scene(4, 3) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "tomato", col = NA))) |> display() ## End(Not run)## Not run: vl_scene(4, 3) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "tomato", col = NA))) |> display() ## End(Not run)
datashader's dynspread: pick the smallest spread
radius (up to max_px) at which the shaded pixels become "connected enough" —
the fraction of non-empty pixels with a non-empty neighbour reaches threshold
— then spread() by it. Denser images spread less, sparse ones more, so a mix
of dense and sparse regions all stay legible.
dynspread( grob, max_px = 3L, threshold = 0.5, shape = c("circle", "square"), how = c("over", "add") )dynspread( grob, max_px = 3L, threshold = 0.5, shape = c("circle", "square"), how = c("over", "add") )
grob |
A raster grob (e.g. from |
max_px |
Largest spread radius to consider (non-negative integer). |
threshold |
Target fraction in |
shape |
Neighbourhood shape: |
how |
How overlapping spread pixels combine: |
A raster grob with the chosen spread applied.
spread(), datashade(), datashade_lines().
set.seed(1) g <- datashade_segments(rnorm(2000), rnorm(2000), rnorm(2000), rnorm(2000)) g2 <- dynspread(g)set.seed(1) g <- datashade_segments(rnorm(2000), rnorm(2000), rnorm(2000), rnorm(2000)) g2 <- dynspread(g)
Returns the resolved device-pixel geometry of every keyed element: the two endpoints of a segment, the vertices of a line or polygon, the centre of a round mark, the opposite corners of a rect.
element_geometry(scene)element_geometry(scene)
scene |
A |
This is what a host needs to hit-test exactly without asking the engine per
event. scene_model() gives bounding boxes, which are right for a brush and
misleading for diagonal or thin marks; this gives the shape the box was
standing in for, once, so a browser can compute point-to-geometry distances
locally at whatever rate it likes.
A data frame of key, kind, vertex (1-based within the element),
ring (1-based within the element), x and y. Zero rows if the scene has
no keyed elements.
One row per vertex, grouped by key. What the vertices mean depends on
kind:
kind |
rows | meaning |
segment |
2 | the endpoints |
line, polygon |
k | the vertices, in order; polygon is closed |
path |
k | all rings' vertices concatenated; ring separates them |
point |
1 | the centre |
rect |
2 | opposite corners |
text, roundrect |
2 | the bounding box's corners |
Coordinates are device pixels with y growing down, matching
scene_model()'s boxes and the rendered SVG's coordinate system.
ring groups the vertices of a multi-ring path – a polygon with a hole, or
a multipart feature – so a host can hit-test it the way the engine does.
Without it the concatenated vertices are lossy: treating them as one closed
ring invents a phantom edge from each ring's last vertex to the next ring's
first. Split on ring and test the rings separately. Every other kind is a
single ring, so ring is 1 throughout.
s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(segments_grob(0.1, 0.1, 0.9, 0.9, key = "diagonal")) element_geometry(s)s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(segments_grob(0.1, 0.1, 0.9, 0.9, key = "diagonal")) element_geometry(s)
font_pin() records the fonts a scene resolved to. font_check() re-resolves
the same scene and reports what changed — a different file, a missing file, or
a family that now resolves somewhere else.
font_pin(scene) font_check(scene, pin, on_mismatch = c("warn", "error", "ignore"))font_pin(scene) font_check(scene, pin, on_mismatch = c("warn", "error", "ignore"))
scene |
A |
pin |
A manifest from |
on_mismatch |
|
font_pin(): a vellum_font_pin object. font_check(): invisibly, a
data frame of differences (zero rows when everything matches), with a
status of "changed", "missing" or "new".
vellum's determinism claim is that the same scene renders to the same pixels
on every OS and in CI. Layout, shaping and rasterisation deliver that. Font
resolution does not, and cannot: "sans" is a different file on macOS,
Linux and Windows, and even the same family name can resolve to a different
version of the same font.
So the claim holds only if the fonts are the same, and until now nothing
checked. A pin turns "identical pixels" from an assumption into an assertion:
record the manifest next to a reference image, and font_check() will say
whether a pixel difference is your change or the machine's font stack.
It does not make fonts reproducible — it cannot install a font, and vellum deliberately resolves fonts through systemfonts so that it agrees with the rest of the R graphics ecosystem. Bundling font files with a scene would break that agreement and raises licensing questions vellum should not answer for you. The honest tool is a check, not a substitute.
The reliable fix, when you need it, is to register the exact file you mean
with systemfonts::register_font() and pin that.
s <- vl_scene(4, 2) |> draw(text_grob("hello")) pin <- font_pin(s) pin # In a test, next to a reference image: nrow(font_check(s, pin)) == 0s <- vl_scene(4, 2) |> draw(text_grob("hello")) pin <- font_pin(s) pin # In a test, next to a reference image: nrow(font_check(s, pin)) == 0
Create a linear or radial gradient to use as a fill in vl_gpar(). A gradient
interpolates between colour stops. Its geometry (x1/y1/... or
cx/cy/r) is given in the coordinate system named by units and is
resolved against the viewport at draw time, so the gradient transforms with
the grob just like its outline.
linear_gradient( colours, stops = NULL, x1 = 0, y1 = 0, x2 = 1, y2 = 0, units = "npc", extend = "pad", interpolation = "srgb" ) radial_gradient( colours, stops = NULL, cx = 0.5, cy = 0.5, r = 0.5, fx = cx, fy = cy, fr = 0, units = "npc", extend = "pad", interpolation = "srgb" )linear_gradient( colours, stops = NULL, x1 = 0, y1 = 0, x2 = 1, y2 = 0, units = "npc", extend = "pad", interpolation = "srgb" ) radial_gradient( colours, stops = NULL, cx = 0.5, cy = 0.5, r = 0.5, fx = cx, fy = cy, fr = 0, units = "npc", extend = "pad", interpolation = "srgb" )
colours |
A vector of two or more colours (any R colour spec). With
|
stops |
Optional offsets in |
x1, y1, x2, y2
|
Start and end points of a linear gradient (default a
left-to-right sweep in |
units |
Coordinate system for the geometry: one of |
extend |
How the gradient behaves outside |
interpolation |
Colour space the stops are blended in: |
cx, cy, r
|
Centre and radius of a radial gradient's outer circle — the
end of the ramp (stop offset 1). Default centred, radius |
fx, fy, fr
|
Centre and radius of the focal (start) circle — the origin
of the ramp (stop offset 0). Defaults ( |
A radial gradient runs between two circles: the focal (start) circle
fx/fy/fr at stop offset 0 and the outer (end) circle cx/cy/r at
offset 1. By default they are concentric (fx = cx, fy = cy, fr = 0) —
the classic centred highlight. Offsetting fx/fy moves the highlight
off-centre (as for a sphere lit from one side); a non-zero fr gives an
annular ramp between the two circles.
By default stops are blended in sRGB (each backend's native behaviour). Set
interpolation = "oklab" to blend in the perceptually-uniform Oklab space
instead, which removes the muddy, over-dark midtones and hue drift of sRGB
blending — the ramp stays even and vivid. interpolation = "oklch" blends in
the polar form of the same space (lightness, chroma, hue): hue and chroma move
independently, so a ramp between two saturated colours keeps its chroma through
the middle instead of dipping toward grey the way a straight line in Oklab can
— at the cost of sweeping through the intermediate hues along the shorter arc
(e.g. blue→yellow passes through green). All modes work identically on the
raster, SVG, and PDF backends.
A vellum_gradient object, suitable for vl_gpar(fill = ...).
linear_gradient(c("white", "navy")) linear_gradient(c("blue", "yellow"), interpolation = "oklab") linear_gradient(c("blue", "yellow"), interpolation = "oklch") radial_gradient(c("yellow", "red"), cx = 0.5, cy = 0.5, r = 0.5) # off-centre highlight (a lit sphere): focal point up and to the left radial_gradient(c("white", "navy"), cx = 0.5, cy = 0.5, r = 0.6, fx = 0.35, fy = 0.65)linear_gradient(c("white", "navy")) linear_gradient(c("blue", "yellow"), interpolation = "oklab") linear_gradient(c("blue", "yellow"), interpolation = "oklch") radial_gradient(c("yellow", "red"), cx = 0.5, cy = 0.5, r = 0.5) # off-centre highlight (a lit sphere): focal point up and to the left radial_gradient(c("white", "navy"), cx = 0.5, cy = 0.5, r = 0.6, fx = 0.35, fy = 0.65)
Grobs are immutable value objects describing something to draw. Build them
with the constructors below, add them to a scene with draw(), and render
with render(). Coordinates accept a vl_unit() vector or a bare numeric
(interpreted in the default_units, usually "npc").
rect_grob( x = 0.5, y = 0.5, width = 1, height = 1, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) roundrect_grob( x = 0.5, y = 0.5, width = 1, height = 1, r = 0.1, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) lines_grob( x, y, arrow = NULL, start_cap = NULL, end_cap = NULL, offset = NULL, sketch = NULL, lwd_profile = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) polygon_grob( x, y, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) bezier_grob( x, y, n = 60, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) spline_grob( x, y, shape = 1, n = 20, open = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) circle_grob( x = 0.5, y = 0.5, r = 0.25, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) points_grob( x, y, size = vl_unit(2, "mm"), shape = "circle", sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) hexagon_grob( x = 0.5, y = 0.5, size = vl_unit(2, "mm"), width = NULL, height = NULL, fill = NULL, orientation = c("flat", "pointy"), gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) sector_grob( x = 0.5, y = 0.5, r0 = 0, r1 = 0.5, theta0 = 0, theta1 = 2 * pi, fill = NULL, arrow = NULL, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) loop_grob( x = 0.5, y = 0.5, size = vl_unit(4, "mm"), foot = vl_unit(0, "mm"), angle = 0, width = 1, arrow = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) segments_grob( x0, y0, x1, y1, arrow = NULL, start_cap = NULL, end_cap = NULL, offset = NULL, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL, col = NULL, lwd = NULL ) path_grob( x, y, id = NULL, rule = c("winding", "evenodd"), sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, role = NULL, key = NULL, meta = NULL ) raster_grob( image, x = 0.5, y = 0.5, width = 1, height = 1, interpolate = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) text_grob( label, x = 0.5, y = 0.5, just = "centre", rot = 0, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, width = NULL, height = NULL, align = "left", fit = FALSE, key = NULL, meta = NULL )rect_grob( x = 0.5, y = 0.5, width = 1, height = 1, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) roundrect_grob( x = 0.5, y = 0.5, width = 1, height = 1, r = 0.1, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) lines_grob( x, y, arrow = NULL, start_cap = NULL, end_cap = NULL, offset = NULL, sketch = NULL, lwd_profile = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) polygon_grob( x, y, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) bezier_grob( x, y, n = 60, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) spline_grob( x, y, shape = 1, n = 20, open = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) circle_grob( x = 0.5, y = 0.5, r = 0.25, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) points_grob( x, y, size = vl_unit(2, "mm"), shape = "circle", sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) hexagon_grob( x = 0.5, y = 0.5, size = vl_unit(2, "mm"), width = NULL, height = NULL, fill = NULL, orientation = c("flat", "pointy"), gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) sector_grob( x = 0.5, y = 0.5, r0 = 0, r1 = 0.5, theta0 = 0, theta1 = 2 * pi, fill = NULL, arrow = NULL, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL ) loop_grob( x = 0.5, y = 0.5, size = vl_unit(4, "mm"), foot = vl_unit(0, "mm"), angle = 0, width = 1, arrow = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) segments_grob( x0, y0, x1, y1, arrow = NULL, start_cap = NULL, end_cap = NULL, offset = NULL, sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, key = NULL, meta = NULL, col = NULL, lwd = NULL ) path_grob( x, y, id = NULL, rule = c("winding", "evenodd"), sketch = NULL, gp = vl_gpar(), name = NULL, vp = NULL, role = NULL, key = NULL, meta = NULL ) raster_grob( image, x = 0.5, y = 0.5, width = 1, height = 1, interpolate = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL ) text_grob( label, x = 0.5, y = 0.5, just = "centre", rot = 0, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL, width = NULL, height = NULL, align = "left", fit = FALSE, key = NULL, meta = NULL )
x, y
|
Coordinates ( |
width, height
|
Grob size ( For |
sketch |
Optional |
gp |
Graphical parameters, from |
name |
Optional name (for |
vp |
Optional |
id |
For most grobs, an optional semantic identifier emitted by the SVG
backend as |
role |
Optional ARIA role, emitted by the SVG backend as |
key |
Optional per-element data key(s) for the batched marks
( |
meta |
Optional free-form per-element metadata for the batched marks: a
list with one entry (record) per element (recycled), e.g. tooltip text or
field values. It never reaches the backend (nothing drawn changes); it rides
on the scene and is returned by |
r |
Radius ( |
arrow |
An |
start_cap, end_cap
|
Optional absolute-length |
offset |
Optional absolute-length |
lwd_profile |
For A varying-width stroke is generated as an outline and filled (no backend
can stroke one path at several widths), so PNG, SVG and PDF all get the same
geometry – it degrades nowhere. Two consequences: the stroke colour paints
the ribbon while |
n |
Number of points to sample the curve at (flattened to a polyline). |
shape |
Marker shape(s): |
open |
If |
size |
Loop extent: an absolute |
fill |
Per-element fill colour(s), recycled to the number of sectors. |
orientation |
Hexagon orientation: |
r0, r1
|
Inner and outer radius of each sector ( |
theta0, theta1
|
Start and end angle of each sector, in radians, with 0 at 3 o'clock and increasing counter-clockwise. |
foot |
Node radius the loop's two feet attach at (an absolute
|
angle |
Outward direction of the loop in radians (which way the teardrop bulges away from the vertex, e.g. away from the layout centroid). |
x0, y0, x1, y1
|
Segment start/end coordinates ( |
col, lwd
|
Reach for these instead of building one grob per segment: varying width or colour that way costs an R-side grob each, which is the dominant expense on scenes made of many small marks. Note that no backend can stroke segments differently in a single call, so each segment is stroked on its own here — the saving is in R, not in the output size. These vary width per element, so the width steps between segments and the
joins show. For width varying smoothly along one line, with no joins to
give it away, see |
rule |
Fill rule: |
image |
A raster image. Either a |
interpolate |
Smoothly interpolate when scaling (default |
label |
Character string(s) to draw. |
just |
Justification: |
rot |
Rotation in degrees, counter-clockwise. |
align |
Alignment of lines within the box: |
fit |
Auto-fit sizing. |
sector_grob() draws a batch of annular sectors (pie / donut / rose wedges) in a
single call. gp$fill recycles per sector; gp$col/lwd give a uniform stroke.
Passing r0 == r1 gives an open arc (stroke only). Combined with an
absolute (mm) radius at a "native" centre and an vl_arrow(), the radius is
resolved to a device length at render (like a marker size), so the arc tracks
an mm size at any page size or dpi; the arrowhead sits tangent to the outer arc's
end. (For node-link self-loops, prefer loop_grob() — a teardrop, not a
ring.)
loop_grob() draws self-loops for node-link diagrams as an igraph-style cubic
Bézier teardrop: it leaves the vertex (x, y) (a "native" anchor), bulges
out to size along angle, and returns, with an optional vl_arrow() head tangent
to the curve at the returning foot. size and foot are absolute and resolved to
device px at render, so the loop is a fixed physical size that scales with the
mm node markers — no native-per-mm estimation, exact at any figure size/dpi.
A grob object.
grobwidth(grob) and grobheight(grob) return a vl_unit() equal to the drawn
width/height of grob — handy for sizing a vl_viewport() or grid_layout()
track to its contents (e.g. a margin to an axis label). The extent is measured
eagerly to absolute millimetres at construction, so it is exact for text
and absolute-unit (mm/in/pt) grobs. A grob sized in npc/native has no
viewport-independent extent and is measured against a fixed reference, so for
those prefer npc/native directly.
grobwidth(grob, mult = 1) grobheight(grob, mult = 1)grobwidth(grob, mult = 1) grobheight(grob, mult = 1)
grob |
A grob (or composite subtree) to measure. |
mult |
A multiplier on the measured extent (default 1). |
A unit (in millimetres).
grobwidth(text_grob("A wide axis label", gp = vl_gpar(fontsize = 14))) grobheight(rect_grob(height = vl_unit(8, "mm")))grobwidth(text_grob("A wide axis label", gp = vl_gpar(fontsize = 14))) grobheight(rect_grob(height = vl_unit(8, "mm")))
Find the topmost node drawn under a point. The scene is compiled into a colour
pick-buffer (each grob drawn in a colour encoding its id, respecting clipping
and paint order) using the same code path that renders it, so the result is
geometry-, clip- and overlap-exact by construction. Markers and text are
matched by their bounding box; lines and segments by a small pick band. grid
ships no equivalent: grid.locator() waits for one interactive click and
returns coordinates rather than a node.
hit_test(scene, x, y, units = c("npc", "px"))hit_test(scene, x, y, units = c("npc", "px"))
scene |
A |
x, y
|
Query point, in |
units |
|
The hit node's name (character); NA_character_ if the topmost grob
there is unnamed; or NULL if nothing is drawn at the point.
md() builds a styled label from a small markdown/HTML-free subset, for use as
the label of text_grob() (and anywhere a label is measured with
grobwidth()/grobheight()). The base font/size/colour come from gp; markup
spans override per run.
md(text)md(text)
text |
A markup string (or a character vector for per-element labels). |
Supported markup:
**bold**
*italic* or _italic_
^sup^ (superscript) and ~sub~ (subscript)
[text]{#c00} — a coloured span (any R colour: name or hex)
Spans nest (e.g. **a^2^**). md() with no markup is equivalent to the plain
string. Embedded newlines (\n) start a new line (stacked baseline-to-baseline).
md() is vectorised: a length-1 input returns a single vellum_md_label; a
longer vector returns a list of them (one per element), so a vellumplot mark can
carry a per-datum rich label.
A vellum_md_label (length-1 text) or a list of them (length > 1).
lab <- md("R^2^ = **0.91**") labs <- md(c("*a*", "**b**")) # a list of two labelslab <- md("R^2^ = **0.91**") labs <- md(c("*a*", "**b**")) # a list of two labels
node_names() lists the names in a scene. get_node() returns the first node
with a given name. edit_node() returns a new scene with that node's
properties updated (copy-on-modify).
node_names(scene) get_node(scene, name) edit_node(scene, name, ...)node_names(scene) get_node(scene, name) edit_node(scene, name, ...)
scene |
A |
name |
A node name (set via the |
... |
Properties to set, e.g. |
node_names(): character. get_node(): a node. edit_node(): a
vellum_scene.
render() writes one page. This writes a document: a report's worth of
figures, one facet per page, an animation as a contact sheet.
pdf_pages(scenes, path = NULL)pdf_pages(scenes, path = NULL)
scenes |
A list of scenes (each a |
path |
Output file. |
Pages may differ in size — each carries its own page box — so a landscape figure can sit between two portrait ones.
Tagging follows each page's own metadata, exactly as it does for a
single-page render (see vignette("accessible-output")): the pages are drawn
by the same code, so a document cannot drift from a single-page file.
path invisibly, or a raw vector when path is NULL.
render() for a single page.
pages <- lapply(c("tomato", "steelblue", "seagreen"), function(col) { vl_scene(4, 3, dpi = 96, bg = "white") |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = col))) }) f <- tempfile(fileext = ".pdf") pdf_pages(pages, f) file.size(f) > 0pages <- lapply(c("tomato", "steelblue", "seagreen"), function(col) { vl_scene(4, 3, dpi = 96, bg = "white") |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = col))) }) f <- tempfile(fileext = ".pdf") pdf_pages(pages, f) file.size(f) > 0
Attributes render cost to the individual marks that caused it, and splits the three phases a render goes through: build (constructing the R value), compile (the R→Rust replay, including text shaping), and raster (drawing). Turns "this plot is slow" into a specific answer.
profile_render(scene, reps = 3)profile_render(scene, reps = 3)
scene |
A |
reps |
Number of render repetitions to time; the median is reported. |
Read the phase split first. Compile time is R-side — S7 property validation, grob construction, unit records — and on scenes with many small grobs it routinely dominates, in which case no amount of backend tuning will help. The per-node table only accounts for raster time.
Timing is armed only for this call, so ordinary renders pay nothing for it.
A data frame ordered by cost, with columns kind, name, n
(elements), seconds and pct (share of raster time). Rows are aggregated
per mark — a vectorised text_grob() of 200 labels compiles to 200 nodes
but reports as one row — so the table reads as "which of my marks is
expensive". The phase split is attached as the "phases" attribute and
shown when printed.
set.seed(1) s <- vl_scene(4, 3) |> draw(points_grob(runif(2000), runif(2000))) |> draw(text_grob("title", y = 0.95, gp = vl_gpar(fontsize = 16))) profile_render(s)set.seed(1) s <- vl_scene(4, 3) |> draw(points_grob(runif(2000), runif(2000))) |> draw(text_grob("title", y = 0.95, gp = vl_gpar(fontsize = 16))) profile_render(s)
Renders a list of independent scenes across cores. This is
embarrassingly parallel — one whole scene per worker, nothing shared — which
is why it exists while tiling a single raster across threads does not:
that needs synchronised access to one pixmap and is declined in
PERFORMANCE.md.
render_all(scenes, paths, workers = NULL, ...)render_all(scenes, paths, workers = NULL, ...)
scenes |
A named or unnamed list of scenes. |
paths |
Output paths, one per scene. The format of each comes from its
extension, exactly as in |
workers |
Number of parallel workers. Defaults to one per available core, capped at the number of scenes. |
... |
Passed to |
The saving is real when the scenes are substantial and there are several of
them. For a handful of small figures the process/thread overhead dominates and
a plain lapply(scenes, render) is as fast; this reports enough to tell.
The paths written, invisibly.
scenes <- list( a = vl_scene(3, 2, dpi = 96) |> draw(circle_grob(gp = vl_gpar(fill = "tomato"))), b = vl_scene(3, 2, dpi = 96) |> draw(rect_grob(gp = vl_gpar(fill = "steelblue"))) ) render_all(scenes, file.path(tempdir(), c("a.png", "b.png")))scenes <- list( a = vl_scene(3, 2, dpi = 96) |> draw(circle_grob(gp = vl_gpar(fill = "tomato"))), b = vl_scene(3, 2, dpi = 96) |> draw(rect_grob(gp = vl_gpar(fill = "steelblue"))) ) render_all(scenes, file.path(tempdir(), c("a.png", "b.png")))
Compares two scenes structurally and reports the differences in terms of the
scene — "the node axis-x moved", "3 marks changed fill" — rather than as a
pixel diff.
scene_diff(a, b, max_depth = 40L)scene_diff(a, b, max_depth = 40L)
a, b
|
Two scenes ( |
max_depth |
Stop descending after this many levels (guards a runaway report on deeply nested trees). |
This is a better basis for visual-regression testing than comparing rendered images, for one specific reason: an image diff is sensitive to the font stack, so the same code compared across two machines produces differences that swamp the real change. A structural diff compares what was asked for, and is unaffected.
A data frame with columns path (where in the tree), change
("added", "removed", or "changed"), and detail. Empty when the two
scenes are equivalent. Printing shows a readable summary.
scene_hash() for a yes/no answer, as_scene_spec().
a <- vl_scene(3, 2) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "red"))) b <- vl_scene(3, 2) |> draw(circle_grob(r = 0.4, gp = vl_gpar(fill = "blue"))) scene_diff(a, b)a <- vl_scene(3, 2) |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "red"))) b <- vl_scene(3, 2) |> draw(circle_grob(r = 0.4, gp = vl_gpar(fill = "blue"))) scene_diff(a, b)
Reports the font files a scene's text resolved to, read off the shaped glyphs rather than re-resolved from family names — so it says what was used, not what would be picked if asked again.
scene_fonts(scene)scene_fonts(scene)
scene |
A |
A data frame with path, index (the face within a font collection),
glyphs (how many glyphs came from that face), file (the basename) and
exists. Zero rows if the scene has no text.
font_pin() to record this and check it later.
s <- vl_scene(4, 2) |> draw(text_grob("hello", gp = vl_gpar(fontfamily = "serif"))) scene_fonts(s)s <- vl_scene(4, 2) |> draw(text_grob("hello", gp = vl_gpar(fontfamily = "serif"))) scene_fonts(s)
A stable hash of everything that defines the scene — geometry, style, page size, background. Two scenes that would render identically hash identically; any change to what is drawn changes the hash.
scene_hash(scene)scene_hash(scene)
scene |
A |
Useful for caching (has this scene changed since I last rendered it?) and for asserting in tests that a refactor did not alter the scene.
A length-1 character hash.
scene_diff() to see what changed, as_scene_spec().
a <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "red"))) b <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "blue"))) scene_hash(a) == scene_hash(a) scene_hash(a) == scene_hash(b)a <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "red"))) b <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "blue"))) scene_hash(a) == scene_hash(a) scene_hash(a) == scene_hash(b)
Places a whole scene inside a region of another one. Because a scene is a retained tree with a known resolved size, this is a graft rather than a re-render: the guest's grobs are spliced under a viewport of the host, and the result is an ordinary scene you can keep building on, edit by name, or inset again.
scene_inset( host, guest, x = 0.5, y = 0.5, width = 0.3, height = 0.3, name = NULL, ... )scene_inset( host, guest, x = 0.5, y = 0.5, width = 0.3, height = 0.3, name = NULL, ... )
host |
The scene to place into. |
guest |
The scene to place. Its page size, background and dpi are not carried over — it becomes a region of the host, which owns those. |
x, y, width, height
|
The region, in the host's current coordinates. |
name |
Optional name for the region's viewport, so the inset can later
be found with |
... |
Further arguments to |
This is the mechanism a composition layer needs — inset maps, small multiples assembled from independently-built plots, a legend built separately and dropped into a corner. The policy (should panel edges align across composed plots? should axes be shared?) belongs above vellum, in a grammar; what the engine provides is the ability to nest one scene in another at all.
The guest's npc coordinates become relative to the region it is placed in,
so a guest built on a square page and inset into a wide region will stretch.
Match the aspect ratio, or build the guest at the aspect you want.
A vellum_scene.
vl_viewport(), as_scene_spec()
main <- vl_scene(4, 3) |> draw(rect_grob(gp = vl_gpar(fill = "#eef2f6", col = NA))) mini <- vl_scene(1, 1) |> draw(circle_grob(r = 0.4, gp = vl_gpar(fill = "tomato", col = NA))) scene_inset(main, mini, x = 0.8, y = 0.75, width = 0.3, height = 0.35)main <- vl_scene(4, 3) |> draw(rect_grob(gp = vl_gpar(fill = "#eef2f6", col = NA))) mini <- vl_scene(1, 1) |> draw(circle_grob(r = 0.4, gp = vl_gpar(fill = "tomato", col = NA))) scene_inset(main, mini, x = 0.8, y = 0.75, width = 0.3, height = 0.35)
scene_model() walks a rendered scene and returns one row per drawn element of
the keyable marks (points, circles, rects, hexagons, sectors, segments),
pairing each element's grammar-supplied identity — its data key, free-form
meta, grob id/name, and enclosing panel — with its resolved device-pixel
bounding box. It is the host-agnostic bridge underlying interactivity: a host
renders the SVG (each element tagged with data-key, see scene_svg()) and
uses this table to map an event back to the originating datum.
scene_model(scene)scene_model(scene)
scene |
A |
Elements are returned in paint order. key/meta are NA/NULL for marks
drawn without them, so a plain scene still yields a geometry table.
A list with two data frames:
elements — one row per element: key, mark, id, name, panel, the
device-px bbox x0,y0,x1,y1, its centre/size x,y,w,h, and a meta
list-column.
panels — one row per named panel: name and its elements' bounding box
x0,y0,x1,y1.
Like render() with a .png / .pdf path, but returns the encoded document
as a raw vector instead of writing a file. These are the in-memory entry
points for a host that needs the bytes rather than a path — embedding a
base64 data URI in an HTML widget, serving a plot from a web API, or writing
to a connection — without a temp-file round-trip.
scene_png(scene, scale = 1, cvd = "none") scene_pdf(scene)scene_png(scene, scale = 1, cvd = "none") scene_pdf(scene)
scene |
A |
scale |
Resolution multiplier (default |
cvd |
Simulate a colour-vision deficiency: |
Together with scene_svg() (a string) and scene_raster() (pixels), every
output format vellum supports can now be produced without touching disk.
Backend degradation warnings (see render()) are surfaced here too, so a PDF
that could not honour a pattern or mask reports it exactly as writing a file
would.
A raw vector: the encoded PNG or PDF document.
render(), scene_svg(), scene_raster()
s <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue"))) png_bytes <- scene_png(s) length(png_bytes) rawToChar(png_bytes[2:4]) # "PNG"s <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue"))) png_bytes <- scene_png(s) length(png_bytes) rawToChar(png_bytes[2:4]) # "PNG"
scene_raster() renders scene and returns its pixels as an integer array
with dimensions c(channel, x, y) — RGBA channels in 0:255, top-left origin,
y increasing downward. This is the form most convenient for probing or
testing (e.g. scene_raster(s)[1, x, y] is the red value at pixel (x, y)).
scene_raster(scene, cvd = "none")scene_raster(scene, cvd = "none")
scene |
A |
cvd |
Simulate a colour-vision deficiency: |
An grDevices::as.raster() method returns the same image as a raster object
(a character matrix of hex colours), drawable with graphics::plot() or
grid::rasterGrob().
scene_raster(): an integer array of dimension c(4, width, height).
The as.raster() method: a raster (character matrix, c(height, width)).
s <- vl_scene(2, 1, bg = "white") |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "red", col = NA))) dim(scene_raster(s)) # c(4, width_px, height_px)s <- vl_scene(2, 1, bg = "white") |> draw(circle_grob(r = 0.3, gp = vl_gpar(fill = "red", col = NA))) dim(scene_raster(s)) # c(4, width_px, height_px)
How much of the canvas a scene actually covers, and how hard it is working to
do it. overplot is the honest signal for "should this be
datashade()-ed?": a count of marks says nothing about whether they overlap,
whereas thirty thousand well-separated points are fine and eight thousand
piled on top of each other are not.
scene_stats(scene)scene_stats(scene)
scene |
A |
A one-row data frame:
elementstotal drawn elements.
inkfraction of canvas pixels differing from the background.
coloursdistinct colours in the rendered image.
overplotsummed element-box area divided by inked area — an
estimate of how many times an average inked pixel was drawn over. 1
means no overlap. It is computed from bounding boxes, so it
over-estimates for non-rectangular marks (a circle fills ~79% of its
box); treat it as an index, not a measurement. Marks the element table
does not cover — text, and unkeyed lines/polygons/paths — contribute to
ink but not to overplot.
vl_lint(), profile_render(), datashade()
set.seed(1) sparse <- vl_scene(4, 3) |> draw(points_grob(runif(200), runif(200))) dense <- vl_scene(4, 3) |> draw(points_grob(rnorm(20000, 0.5, 0.05), rnorm(20000, 0.5, 0.05))) rbind(sparse = scene_stats(sparse), dense = scene_stats(dense))set.seed(1) sparse <- vl_scene(4, 3) |> draw(points_grob(runif(200), runif(200))) dense <- vl_scene(4, 3) |> draw(points_grob(rnorm(20000, 0.5, 0.05), rnorm(20000, 0.5, 0.05))) rbind(sparse = scene_stats(sparse), dense = scene_stats(dense))
Like render() with an .svg path, but returns the SVG document as a
character string instead of writing a file. This is the in-memory entry point
for hosting a scene interactively (an htmlwidget embeds the markup directly)
and for tests that assert on emitted attributes such as data-key.
scene_svg(scene, text = c("native", "outline"))scene_svg(scene, text = c("native", "outline"))
scene |
A |
text |
For SVG output, how text is written: |
A length-1 character vector: the SVG document.
render(), scene_png(), scene_model()
Persist a scene so it can be rebuilt later, in another session, or in another
process. .rds is the default and needs nothing extra; .json produces a
portable text format and needs the jsonlite package.
scene_write(scene, path) scene_read(path)scene_write(scene, path) scene_read(path)
scene |
A |
path |
File path. The extension picks the format ( |
What this buys you: caching a built scene without re-running the code that built it, sending one over a wire for a worker to render, and keeping a reproducible intermediate between "the code" and "the pixels".
scene_write(): path, invisibly. scene_read(): a vellum_scene.
as_scene_spec(), scene_diff(), scene_hash()
s <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue"))) f <- tempfile(fileext = ".rds") scene_write(s, f) identical(as_scene_spec(scene_read(f)), as_scene_spec(s))s <- vl_scene(2, 2) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue"))) f <- tempfile(fileext = ".rds") scene_write(s, f) identical(as_scene_spec(scene_read(f)), as_scene_spec(s))
Attach to a grob's sketch argument to render it in a hand-drawn, sketchy
style (the Rough.js look): wobbly outlines and hachure
fills. Supported by rect_grob(), polygon_grob(), lines_grob(), and
circle_grob(). Output is deterministic given seed.
sketch( roughness = 1, bowing = 1, fill_style = c("hachure", "solid", "crosshatch", "zigzag", "dots"), fill_weight = NULL, hachure_angle = -41, hachure_gap = NULL, curve_tightness = 0, disable_multi_stroke = FALSE, preserve_vertices = FALSE, seed = 1L )sketch( roughness = 1, bowing = 1, fill_style = c("hachure", "solid", "crosshatch", "zigzag", "dots"), fill_weight = NULL, hachure_angle = -41, hachure_gap = NULL, curve_tightness = 0, disable_multi_stroke = FALSE, preserve_vertices = FALSE, seed = 1L )
roughness |
Wobble amount ( |
bowing |
How much straight edges bow (0 disables bowing). |
fill_style |
One of |
fill_weight |
Stroke width of fill/hachure lines, in |
hachure_angle |
Hachure line angle in degrees. |
hachure_gap |
Gap between hachure lines, in |
curve_tightness |
Curve fit tightness for round shapes (circles, arcs). |
disable_multi_stroke |
If |
preserve_vertices |
If |
seed |
Integer seed for the wobble (same seed => identical output). |
Sketch is a deliberate exception to vellum's crisp, fidelity-first defaults —
see vignette / _docs/DESIGN-ROUGHR.md. Text is never sketched.
A vellum_sketch object for a grob's sketch argument.
rect_grob(gp = vl_gpar(fill = "steelblue", col = "black"), sketch = sketch())rect_grob(gp = vl_gpar(fill = "steelblue", col = "black"), sketch = sketch())
Grow each non-empty pixel of a raster over a small neighbourhood so thin marks
stay visible. This is datashader's spread: useful
after datashade(), datashade_lines(), or datashade_segments() when the
output is sparse (single-pixel lines, isolated points). dynspread() chooses
the radius automatically from the image's density; spread() uses a fixed one.
spread(grob, px = 1L, shape = c("circle", "square"), how = c("over", "add"))spread(grob, px = 1L, shape = c("circle", "square"), how = c("over", "add"))
grob |
A raster grob (e.g. from |
px |
Spread radius in pixels (non-negative integer). |
shape |
Neighbourhood shape: |
how |
How overlapping spread pixels combine: |
A raster grob with the spread applied.
dynspread(), datashade(), datashade_lines().
set.seed(1) g <- datashade_segments(rnorm(2000), rnorm(2000), rnorm(2000), rnorm(2000)) g2 <- spread(g, px = 2)set.seed(1) g <- datashade_segments(rnorm(2000), rnorm(2000), rnorm(2000), rnorm(2000)) g2 <- spread(g, px = 2)
Converts the stroke of a line-like grob into a path_grob describing the
region the stroke covers — the shape you would get by tracing round the drawn
line. What was a one-pixel-wide path with a colour becomes an area with an
interior, which is what you need in order to fill it with a gradient or a
pattern, to send it to a cutting plotter or CNC tool, or to do geometry with
it.
stroke_to_path( grob, width = 6, height = 4, dpi = 96, gp = NULL, lwd_profile = NULL, along = c("arclength", "vertex") )stroke_to_path( grob, width = 6, height = 4, dpi = 96, gp = NULL, lwd_profile = NULL, along = c("arclength", "vertex") )
grob |
A |
width, height
|
Page size in inches to resolve the geometry against. |
dpi |
Resolution to resolve the stroke width against. |
gp |
Optional |
lwd_profile |
Optional numeric vector of multipliers of |
along |
How |
At constant width the expansion uses the same stroker the rasterizer uses, so
the outline is exactly the region that would have been inked, not a
reimplementation that could drift from it. A varying width has no such
reference — no rasterizer can stroke a line at a changing width — so
lwd_profile is served by vellum's own offsetter instead. See
Variable width below.
The result is baked at one size. A stroke width is a device quantity, so its outline only exists once a page size and resolution are chosen. Those are arguments here, and the returned coordinates are absolute (mm) — the outline will not rescale with the page the way the original stroke would. That is inherent: an outline is a shape, not a stroke.
A path_grob() in mm units, with rule = "winding", whose fill is
the stroked region. Its gp starts from the source grob's stroke colour as
a fill, so drawing it looks like the original line.
The region swept by a round nib of varying radius is the union of the convex
hulls of successive pairs of end discs, so lwd_profile builds that union
rather than offsetting each vertex along its normal. The distinction matters
at a taper: an offset boundary would cut into the end disc and the tip would
come out waisted rather than pointed.
Three consequences worth knowing:
Width varies linearly between vertices, so a profile is only as smooth
as the polyline under it. bezier_grob() and spline_grob() flatten to a
polyline before this function sees them, so a profile rides the flattened
points — prefer along = "arclength" there, and raise their n if a taper
looks faceted.
On a closed polygon_grob() or path_grob() the profile is cyclic: a
ring has no first vertex a reader can see, so the values wrap. c(1, 0) on
a ring is therefore two tapers, not one.
lwd_profile = NULL is byte-identical to previous versions. A constant
profile such as lwd_profile = 1 is not — it goes through the offsetter, so
it agrees with tiny-skia's stroker as a picture but not to the last bit.
lineend and linejoin are honoured, but only on the outer silhouette; the
inner side of a join is the natural overlap either way.
path_grob(), grob, segments_grob() for per-element widths
zig <- lines_grob(c(0.1, 0.35, 0.6, 0.9), c(0.2, 0.8, 0.2, 0.8), gp = vl_gpar(col = "steelblue", lwd = 12)) outline <- stroke_to_path(zig, width = 3, height = 2) # A width that varies along the line: thick in the middle, tapered at both ends. leaf <- stroke_to_path(zig, width = 3, height = 2, lwd_profile = c(0.15, 1, 0.15)) # Now fillable: a gradient across the ribbon the line traced. vl_scene(3, 2) |> draw(S7::set_props(outline, gp = vl_gpar( fill = linear_gradient(c("tomato", "gold")), col = "grey20", lwd = 0.5 )))zig <- lines_grob(c(0.1, 0.35, 0.6, 0.9), c(0.2, 0.8, 0.2, 0.8), gp = vl_gpar(col = "steelblue", lwd = 12)) outline <- stroke_to_path(zig, width = 3, height = 2) # A width that varies along the line: thick in the middle, tapered at both ends. leaf <- stroke_to_path(zig, width = 3, height = 2, lwd_profile = c(0.15, 1, 0.15)) # Now fillable: a gradient across the ribbon the line traced. vl_scene(3, 2) |> draw(S7::set_props(outline, gp = vl_gpar( fill = linear_gradient(c("tomato", "gold")), col = "grey20", lwd = 0.5 )))
A style bundles vl_gpar() graphical parameters under an optional name so the
same look can be reused across many viewports or grobs. A style is a
gpar — it carries every graphical-parameter field and obeys the same
inheritance rules — with an added name for identification. It can therefore
be passed anywhere a gp is accepted.
style( col = NULL, fill = NULL, lwd = NULL, alpha = NULL, lty = NULL, lineend = NULL, linejoin = NULL, linemitre = NULL, fontfamily = NULL, fontface = NULL, fontsize = NULL, cex = NULL, lineheight = NULL, halo_col = NULL, halo_width = NULL, features = NULL, antialias = NULL, crisp = NULL, dash_phase = NULL, name = NULL )style( col = NULL, fill = NULL, lwd = NULL, alpha = NULL, lty = NULL, lineend = NULL, linejoin = NULL, linemitre = NULL, fontfamily = NULL, fontface = NULL, fontsize = NULL, cex = NULL, lineheight = NULL, halo_col = NULL, halo_width = NULL, features = NULL, antialias = NULL, crisp = NULL, dash_phase = NULL, name = NULL )
col |
Stroke/text colour, or a gradient from |
fill |
Fill colour, or a gradient from |
lwd |
Line width (1 == 1/96 inch). |
alpha |
Opacity multiplier in |
lty |
Line type: a name ( |
lineend |
Line cap: |
linejoin |
Line join: |
linemitre |
Mitre limit (>= 1) for mitre joins; default 10. |
fontfamily |
Font family (text grobs). |
fontface |
One of |
fontsize |
Font size in points. |
cex |
Multiplier applied to |
lineheight |
Line-height multiple. |
halo_col |
Halo colour for text (a "shadowtext" outline drawn under
the glyphs, so a label stays legible over dense marks or map imagery).
|
halo_width |
Halo thickness in points – the visible width outside the
glyph. |
features |
OpenType font features, as a named numeric vector of
four-character feature tags – e.g. |
antialias |
Anti-alias this element's edges? |
crisp |
Snap axis-parallel strokes onto the pixel grid? |
dash_phase |
How far into the dash pattern a dashed line starts, as a
multiple of |
name |
Optional style-class name, for identification only; it is ignored by rendering. |
Attaching a style to a viewport cascades its defaults to the whole subtree
via the ordinary gpar inheritance (more-specific overrides less-specific), so
a child grob's own gp still wins. This is the reusable "style class" layer
that sits below a grammar's themes: a theme can compile into named styles
rather than setting gpar fields ad hoc on every element.
A style object (a subclass of gpar).
accent <- style(col = "firebrick", lwd = 2, name = "accent") # Reuse it on a viewport; children inherit unless they override. vl_viewport(gp = accent)accent <- style(col = "firebrick", lwd = 2, name = "accent") # Reuse it on a viewport; children inherit unless they override. vl_viewport(gp = accent)
text_path_grob() places a label along a polyline baseline instead of at a
point: each glyph keeps the pen position shaping gave it and is drawn at that
distance along the path, rotated to the local tangent. Use it for labels that
follow an arc, a contour, a river or a curved axis.
text_path_grob( label, x, y, just = "centre", offset = 0, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL )text_path_grob( label, x, y, just = "centre", offset = 0, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL )
label |
A single character string. |
x, y
|
The baseline path, as unit vectors of equal length. |
just |
Justification: |
offset |
Perpendicular standoff from the baseline in points; positive is to the left of the direction of travel. |
gp |
Graphical parameters, from |
name |
Optional name (for |
vp |
Optional |
id |
For most grobs, an optional semantic identifier emitted by the SVG
backend as |
role |
Optional ARIA role, emitted by the SVG backend as |
The path is a sequence of points, exactly like lines_grob() — pass the
output of bezier_grob()-style control points already flattened, or any
polyline. Arc length is measured on the rendered path, so the result adapts
to the viewport the grob is drawn in.
just does double duty: the horizontal component slides the run along the
baseline ("left" starts at the first point, "centre" centres it,
"right" ends at the last), and the vertical component positions the glyphs
against the baseline as it would for ordinary text. For finer control of the
standoff — a label riding just above a curve rather than sitting on it — use
offset.
Glyphs follow the tangent, as in SVG textPath: a label on the underside
of a closed curve reads upside-down. That is the honest result of the
geometry, and the fix is to reverse the path rather than the glyphs — set the
lower arc as a second text_path_grob() whose points run the other way.
Glyphs are placed one per character. For Latin text that is exact; scripts where shaping produces a different number of glyphs than characters (Arabic, Devanagari, and ligature-heavy fonts) will still draw, but the per-glyph text recovered by PDF copy-paste and by SVG native text degrades to the whole label on the first glyph.
A grob.
th <- seq(pi, 0, length.out = 60) vl_scene(4, 2.2, dpi = 96, bg = "white") |> draw(text_path_grob( "text that follows a curve", x = 0.5 + 0.42 * cos(th), y = 0.15 + 0.7 * sin(th), offset = 3, gp = vl_gpar(fontsize = 13) ))th <- seq(pi, 0, length.out = 60) vl_scene(4, 2.2, dpi = 96, bg = "white") |> draw(text_path_grob( "text that follows a curve", x = 0.5 + 0.42 * cos(th), y = 0.15 + 0.7 * sin(th), offset = 3, gp = vl_gpar(fontsize = 13) ))
Describe arrowheads to draw on the ends of a lines_grob() or
segments_grob() (pass as their arrow = argument).
vl_arrow( angle = 30, length = vl_unit(0.25, "in"), ends = c("last", "first", "both"), type = c("open", "closed") )vl_arrow( angle = 30, length = vl_unit(0.25, "in"), ends = c("last", "first", "both"), type = c("open", "closed") )
angle |
Half-angle of the head at the tip, in degrees (default 30). |
length |
Head length as an absolute |
ends |
Which ends get a head: |
type |
|
A vellum_arrow object.
lines_grob(c(0.1, 0.9), c(0.1, 0.9), arrow = vl_arrow(type = "closed"))lines_grob(c(0.1, 0.9), c(0.1, 0.9), arrow = vl_arrow(type = "closed"))
Grows a ring by width in every direction — the exclusion zone around a
region, the halo around a cluster outline, the standoff a leader line should
keep.
vl_buffer(x, y, width, arc = 6)vl_buffer(x, y, width, arc = 6)
x, y
|
The ring's coordinates, in order and not closed. |
width |
Distance to grow by, in the same units as |
arc |
Points per quarter-turn on rounded corners. More is smoother. |
Corners are rounded, which is what a standoff usually wants and what keeps the result well-defined at sharp angles (a mitred corner runs away to infinity as the angle closes).
On concave rings: the offset boundary of a concave polygon can cross
itself where the buffer is wider than a local feature. This function offsets
the ring and does not repair that, because repairing it is a boolean union —
a Phase 12 operation. Buffering a convex ring, or buffering by less than the
narrowest feature, is always well-behaved. Passing the result through
vl_hull() is a crude but effective fix when it is not.
A data frame of the buffered ring's x/y.
tri <- data.frame(x = c(0.3, 0.7, 0.5), y = c(0.3, 0.35, 0.7)) buf <- vl_buffer(tri$x, tri$y, 0.08) vl_scene(3, 3, dpi = 96, bg = "white") |> draw(polygon_grob(buf$x, buf$y, gp = vl_gpar(fill = "#F3E0DA", col = NA))) |> draw(polygon_grob(tri$x, tri$y, gp = vl_gpar(fill = "tomato", col = NA)))tri <- data.frame(x = c(0.3, 0.7, 0.5), y = c(0.3, 0.35, 0.7)) buf <- vl_buffer(tri$x, tri$y, 0.08) vl_scene(3, 3, dpi = 96, bg = "white") |> draw(polygon_grob(buf$x, buf$y, gp = vl_gpar(fill = "#F3E0DA", col = NA))) |> draw(polygon_grob(tri$x, tri$y, gp = vl_gpar(fill = "tomato", col = NA)))
vellum memoises compiled scenes keyed on an object-identity token so repeat
renders of an unchanged scene (multi-format export, a display() resize back
to a prior size, or animation replaying a fixed set of frames) are cheap. The
cache is transparent — a cached render is byte-identical to an uncached one —
and bounded (options(vellum.cache_size=), default 8), so you rarely need
this; it is provided to reclaim memory or to force a cold render in
benchmarks. Disable caching entirely with options(vellum.cache = FALSE).
vl_clear_render_cache()vl_clear_render_cache()
NULL, invisibly.
vl_clear_render_cache()vl_clear_render_cache()
Marching squares over a matrix, chained into polylines. Use it for density
isolines over a datashade() surface, level curves of a fitted model, or any
other gridded field.
vl_contour(z, levels = NULL, xlim = c(0, 1), ylim = c(0, 1)) contour_grob( contours, close = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, role = NULL )vl_contour(z, levels = NULL, xlim = c(0, 1), ylim = c(0, 1)) contour_grob( contours, close = TRUE, gp = vl_gpar(), name = NULL, vp = NULL, role = NULL )
z |
A numeric matrix, with rows indexing x and columns indexing y —
|
levels |
Contour levels. Defaults to 5 levels spanning the finite range
of |
xlim, ylim
|
The coordinate range the grid spans. Cell centres are
placed at the ends of these ranges, matching |
contours |
The data frame returned by |
close |
Draw closed contours as closed rings. |
gp, name, vp, role
|
Passed to each |
Segments are chained into continuous lines rather than returned loose. That matters beyond tidiness: an unchained contour restarts its dash pattern in every grid cell, cannot be simplified or measured, and cannot be filled. Closed rings come back closed.
Cells with a non-finite corner are skipped, so a contour breaks around missing data rather than being drawn through it.
A data frame with one row per point: level, id (a distinct
contour line), x, y, and closed. Draw it with
lines_grob(x, y, id = id).
# A ring contour around a Gaussian bump. # rows index x, columns index y -- exactly what `outer(xs, ys, f)` gives. g <- outer(seq(-3, 3, length.out = 60), seq(-3, 3, length.out = 60), function(x, y) exp(-(x^2 + y^2) / 2)) head(vl_contour(g, levels = c(0.2, 0.5, 0.8))) # Draw them: one grob per contour, because each is its own polyline. # rows index x, columns index y -- exactly what `outer(xs, ys, f)` gives. g <- outer(seq(-3, 3, length.out = 60), seq(-3, 3, length.out = 60), function(x, y) exp(-(x^2 + y^2) / 2)) vl_scene(3, 3, dpi = 96, bg = "white") |> push(vl_viewport(xscale = c(-3, 3), yscale = c(-3, 3))) |> draw(contour_grob(vl_contour(g, levels = c(0.2, 0.5, 0.8)), gp = vl_gpar(col = "steelblue"))) |> pop() |> (\(s) s)()# A ring contour around a Gaussian bump. # rows index x, columns index y -- exactly what `outer(xs, ys, f)` gives. g <- outer(seq(-3, 3, length.out = 60), seq(-3, 3, length.out = 60), function(x, y) exp(-(x^2 + y^2) / 2)) head(vl_contour(g, levels = c(0.2, 0.5, 0.8))) # Draw them: one grob per contour, because each is its own polyline. # rows index x, columns index y -- exactly what `outer(xs, ys, f)` gives. g <- outer(seq(-3, 3, length.out = 60), seq(-3, 3, length.out = 60), function(x, y) exp(-(x^2 + y^2) / 2)) vl_scene(3, 3, dpi = 96, bg = "white") |> push(vl_viewport(xscale = c(-3, 3), yscale = c(-3, 3))) |> draw(contour_grob(vl_contour(g, levels = c(0.2, 0.5, 0.8)), gp = vl_gpar(col = "steelblue"))) |> pop() |> (\(s) s)()
The counterpart to grid's convertWidth()/convertHeight()/convertX()/
convertY(): resolve a vl_unit() vector to a plain number in some other
unit. why_size() explains a named node's resolved size and
scene_model() reports resolved boxes for keyed elements; this answers the
remaining question — "how big is this particular unit, here?" — which a layer
built on vellum needs whenever it has to size something itself.
vl_convert( u, to = "mm", scene = NULL, name = NULL, axis = c("x", "y"), what = c("length", "position") )vl_convert( u, to = "mm", scene = NULL, name = NULL, axis = c("x", "y"), what = c("length", "position") )
u |
A |
to |
Target unit: |
scene |
A |
name |
Name of the viewport to resolve against. |
axis |
Which extent relative units refer to: |
what |
|
Absolute units (mm, cm, in, pt) convert with no context, so scene
may be omitted. Relative units (npc, native) need to know the region they
are relative to, and therefore need a scene — and, for anything other than
the whole page, the name of a viewport in it.
Where grid uses four functions, this uses two arguments: axis picks the
x or y extent, and what distinguishes a length from a position.
They differ only for native, and only when the scale does not start at
zero: on xscale = c(10, 20), unit(12, "native") is two tenths of the
width as a position, but twelve tenths as a length.
A numeric vector the same length as u, in to units.
why_size(), scene_model(), vl_unit()
s <- vl_scene(4, 3, dpi = 100) vl_convert(vl_unit(1, "in"), "mm") # absolute: no scene needed vl_convert(vl_unit(0.5, "npc"), "mm", s) # half the page width vl_convert(vl_unit(0.5, "npc"), "mm", s, axis = "y") # A named viewport, and the length/position distinction on a shifted scale. s2 <- s |> push(vl_viewport(width = 0.5, xscale = c(10, 20), name = "panel")) vl_convert(vl_unit(12, "native"), "mm", s2, name = "panel") vl_convert(vl_unit(12, "native"), "mm", s2, name = "panel", what = "position")s <- vl_scene(4, 3, dpi = 100) vl_convert(vl_unit(1, "in"), "mm") # absolute: no scene needed vl_convert(vl_unit(0.5, "npc"), "mm", s) # half the page width vl_convert(vl_unit(0.5, "npc"), "mm", s, axis = "y") # A named viewport, and the length/position distinction on a shifted scale. s2 <- s |> push(vl_viewport(width = 0.5, xscale = c(10, 20), name = "panel")) vl_convert(vl_unit(12, "native"), "mm", s2, name = "panel") vl_convert(vl_unit(12, "native"), "mm", s2, name = "panel", what = "position")
Answers "where is there room?" — for a legend, an annotation, a watermark, or anything else that has to go somewhere the marks are not.
vl_empty_region( scene, avoid = NULL, within = NULL, grid = 200, unit = c("npc", "mm") )vl_empty_region( scene, avoid = NULL, within = NULL, grid = 200, unit = c("npc", "mm") )
scene |
A |
avoid |
Names of the grobs to treat as obstacles. |
within |
Region to search, as |
grid |
Grid resolution (cells across the longer side). |
unit |
Unit of the returned rectangle: |
Occupancy is rasterised onto a grid and the answer is exact on that grid.
The approximation is deliberate: the exact maximal empty rectangle over n
boxes is superquadratic, and nothing is placed to sub-pixel tolerance. Raise
grid to trade time for precision. Boxes are rounded outward, so the result
is conservative — it will never claim space that is in fact occupied.
A named numeric c(x0, y0, x1, y1), in unit, using the usual
y-grows-up convention. All zeros if there is no free space.
set.seed(1) s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(points_grob(runif(40) * 0.5, runif(40), gp = vl_gpar(fill = "steelblue", col = NA))) vl_empty_region(s)set.seed(1) s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(points_grob(runif(40) * 0.5, runif(40), gp = vl_gpar(fill = "steelblue", col = NA))) vl_empty_region(s)
Builds a set of graphical parameters attached to a grob or viewport. Any field
left NULL is inherited from the enclosing viewport; alpha multiplies down
the viewport tree. A colour value sets it; NA means "no paint".
vl_gpar( col = NULL, fill = NULL, lwd = NULL, alpha = NULL, lty = NULL, lineend = NULL, linejoin = NULL, linemitre = NULL, fontfamily = NULL, fontface = NULL, fontsize = NULL, cex = NULL, lineheight = NULL, halo_col = NULL, halo_width = NULL, features = NULL, antialias = NULL, crisp = NULL, dash_phase = NULL )vl_gpar( col = NULL, fill = NULL, lwd = NULL, alpha = NULL, lty = NULL, lineend = NULL, linejoin = NULL, linemitre = NULL, fontfamily = NULL, fontface = NULL, fontsize = NULL, cex = NULL, lineheight = NULL, halo_col = NULL, halo_width = NULL, features = NULL, antialias = NULL, crisp = NULL, dash_phase = NULL )
col |
Stroke/text colour, or a gradient from |
fill |
Fill colour, or a gradient from |
lwd |
Line width (1 == 1/96 inch). |
alpha |
Opacity multiplier in |
lty |
Line type: a name ( |
lineend |
Line cap: |
linejoin |
Line join: |
linemitre |
Mitre limit (>= 1) for mitre joins; default 10. |
fontfamily |
Font family (text grobs). |
fontface |
One of |
fontsize |
Font size in points. |
cex |
Multiplier applied to |
lineheight |
Line-height multiple. |
halo_col |
Halo colour for text (a "shadowtext" outline drawn under
the glyphs, so a label stays legible over dense marks or map imagery).
|
halo_width |
Halo thickness in points – the visible width outside the
glyph. |
features |
OpenType font features, as a named numeric vector of
four-character feature tags – e.g. |
antialias |
Anti-alias this element's edges? |
crisp |
Snap axis-parallel strokes onto the pixel grid? |
dash_phase |
How far into the dash pattern a dashed line starts, as a
multiple of |
A gpar object.
vl_gpar(col = "steelblue", lwd = 2, lty = "dashed", lineend = "round")vl_gpar(col = "steelblue", lwd = 2, lty = "dashed", lineend = "round")
Fills a shape with ruled parallel lines. Unlike vl_pattern(), which
rasterises a tile, a hatch is geometry: it stays crisp at any zoom, prints
correctly, and survives being converted to greyscale.
vl_hatch(angle = 45, spacing = 3, width = 0.75, col = "black", bg = NA)vl_hatch(angle = 45, spacing = 3, width = 0.75, col = "black", bg = NA)
angle |
Direction of the rules, in degrees counter-clockwise from horizontal. Distinct angles (0, 45, 90, 135) read as distinct categories. |
spacing |
Distance between rules, in points. |
width |
Rule width, in points. |
col |
Rule colour. |
bg |
Optional background painted behind the rules. |
That last point is the reason to reach for it. A colour encoding that fails
for a red/green-blind reader — which render()'s cvd argument will show you
and vl_lint() will flag — is fixed by encoding with texture as well as
hue. Hatching is the standard way to do that.
A vellum_hatch object, usable anywhere a fill is.
vl_pattern() for a tiled grob, linear_gradient(), vl_lint()
vl_scene(3, 2) |> draw(rect_grob(width = 0.8, height = 0.8, gp = vl_gpar( fill = vl_hatch(angle = 45, spacing = 4), col = "grey30" )))vl_scene(3, 2) |> draw(rect_grob(width = 0.8, height = 0.8, gp = vl_gpar( fill = vl_hatch(angle = 45, spacing = 4), col = "grey30" )))
vl_hull() returns the convex hull, or a concave one that follows the point
set more closely — useful for outlining a cluster, or for building an
exclusion zone that annotations should avoid.
vl_hull(x, y, concavity = Inf)vl_hull(x, y, concavity = Inf)
x, y
|
Point coordinates (plain numerics, in whatever space you are working in — the hull is scale-free). |
concavity |
Threshold as above; larger is more convex, and |
The concave hull digs into the convex hull, replacing an edge with two edges
through the nearest unused point whenever the edge is long relative to the
detour. concavity is that threshold, and larger means more convex:
Inf (the default) is exactly the convex hull.
8 follows the points loosely.
4 is a good tight outline for a scattered cluster.
Below about 3 the boundary starts to self-intersect.
Self-intersection is inherent to the method, not a defect to be fixed here — a boundary that threads between interior points has to cross itself eventually. Inspect the result if you push it hard.
A data frame of the hull ring's x/y, in order, ready for
polygon_grob(). The ring is not closed (the last point does not repeat
the first).
set.seed(1) pts <- data.frame(x = runif(40), y = runif(40)) h <- vl_hull(pts$x, pts$y) vl_scene(3, 3, dpi = 96, bg = "white") |> draw(polygon_grob(h$x, h$y, gp = vl_gpar(fill = "#DCE7F5", col = "steelblue"))) |> draw(points_grob(pts$x, pts$y, gp = vl_gpar(fill = "grey25", col = NA)))set.seed(1) pts <- data.frame(x = runif(40), y = runif(40)) h <- vl_hull(pts$x, pts$y) vl_scene(3, 3, dpi = 96, bg = "white") |> draw(polygon_grob(h$x, h$y, gp = vl_gpar(fill = "#DCE7F5", col = "steelblue"))) |> draw(points_grob(pts$x, pts$y, gp = vl_gpar(fill = "grey25", col = NA)))
Static analysis for graphics. vl_lint() resolves the scene and inspects the
geometry the renderer would draw with, reporting the things people normally
find by squinting at the output: a mark that landed off-canvas, a label too
small to read, text that will not contrast with what is behind it, an element
that is invisible because nothing was ever going to paint it.
vl_lint( scene, rules = NULL, exclude = NULL, severity = NULL, min_text_px = 7, min_text_pt = 6, min_contrast = 3, max_overplot = 8, cvd = "deuteranopia", min_cvd_delta = 0.06 )vl_lint( scene, rules = NULL, exclude = NULL, severity = NULL, min_text_px = 7, min_text_pt = 6, min_contrast = 3, max_overplot = 8, cvd = "deuteranopia", min_cvd_delta = 0.06 )
scene |
A |
rules |
Rule ids to run; |
exclude |
Node names (or ids, or kinds) whose findings to drop — the
deliberate oddity you have already looked at and accepted. Suppression is by
node because |
severity |
Named character vector overriding a rule's own severity, e.g.
|
min_text_px |
Text shorter than this many device pixels is flagged as
illegible. Default |
min_text_pt |
Text smaller than this many points is flagged as
illegible. Default The two floors answer different questions and |
min_contrast |
Minimum text-to-backdrop contrast ratio before
|
max_overplot |
How many times an average pixel inside a batched mark's
own extent may be painted before |
cvd |
Colour-vision deficiencies |
min_cvd_delta |
How close two colours may come, as an Oklab distance
after simulation, before |
This is possible because vellum resolves layout and text metrics before drawing. A layer above grid cannot ask "how many pixels tall is this label", because the answer does not exist until a device is open.
The rule set is extensible — see vl_lint_rule().
A data frame of findings, empty if the scene is clean: rule,
severity, node, message, and the finding's device-px box
(x0, y0, x1, y1, NA where a rule reported something with no
geometry). Printing shows a grouped summary.
A rule that fails does not abort the lint: it is reported as a
rule_error finding naming the rule, and the other rules still run.
vl_lint_rule(), scene_stats(), why_size()
# A label pushed off the page, and one too small to read. s <- vl_scene(3, 2) |> draw(text_grob("off the edge", x = 1.6, y = 0.5)) |> draw(text_grob("tiny", x = 0.5, y = 0.2, gp = vl_gpar(fontsize = 2))) vl_lint(s)# A label pushed off the page, and one too small to read. s <- vl_scene(3, 2) |> draw(text_grob("off the edge", x = 1.6, y = 0.5)) |> draw(text_grob("tiny", x = 0.5, y = 0.2, gp = vl_gpar(fontsize = 2))) vl_lint(s)
vl_lint() for a test suite or a CI job: run the rules and stop if anything
turns up. A figure that lints clean today stays that way, in the same spirit
as font_pin() and scene_hash() — with the difference that this one
catches defects rather than changes.
vl_lint_assert( scene, ..., severity = c("warning", "note"), on = c("error", "warn") )vl_lint_assert( scene, ..., severity = c("warning", "note"), on = c("error", "warn") )
scene |
A |
... |
Passed to |
severity |
|
on |
|
Invisibly, the findings that triggered the failure — a zero-row frame when the scene is clean.
vl_lint(), vl_lint_overlay() to see what was reported.
clean <- vl_scene(3, 2) |> draw(text_grob("fine", gp = vl_gpar(fontsize = 12))) vl_lint_assert(clean) # In a test: # test_that("the figure has no lint findings", vl_lint_assert(my_plot()))clean <- vl_scene(3, 2) |> draw(text_grob("fine", gp = vl_gpar(fontsize = 12))) vl_lint_assert(clean) # In a test: # test_that("the figure has no lint findings", vl_lint_assert(my_plot()))
The return shape vl_lint_rule() functions must produce. Vectorised over
nodes, so a rule matches rows and describes them in one call.
vl_lint_finding(rule, severity = c("warning", "note"), nodes, message)vl_lint_finding(rule, severity = c("warning", "note"), nodes, message)
rule |
Rule id. |
severity |
|
nodes |
The matching rows of the node table. |
message |
One message, or one per row. |
A data frame of findings, one row per node, with the node's
device-px box carried through in x0/y0/x1/y1 so a caller can point
at the finding on the image. The box is NA when nodes has no geometry.
Puts a box around every finding that has geometry, and labels it with the rules that fired. For a graphics linter this is usually the fastest way to understand a report: a message says a mark is clipped, an outline shows you which one.
vl_lint_overlay(scene, findings = NULL, labels = TRUE)vl_lint_overlay(scene, findings = NULL, labels = TRUE)
scene |
A |
findings |
The result of |
labels |
Whether to write the rule name beside each box. |
Findings with no geometry (a rule_error, or a rule reporting something
scene-wide) have nothing to point at and are skipped.
A new scene: scene with the overlay drawn on top, at page level.
s <- vl_scene(3, 2) |> draw(text_grob("off the edge", x = 1.6, y = 0.5)) |> draw(text_grob("tiny", x = 0.5, y = 0.2, gp = vl_gpar(fontsize = 2))) # display(vl_lint_overlay(s)) nrow(vl_lint(s)) > 0s <- vl_scene(3, 2) |> draw(text_grob("off the edge", x = 1.6, y = 0.5)) |> draw(text_grob("tiny", x = 0.5, y = 0.2, gp = vl_gpar(fontsize = 2))) # display(vl_lint_overlay(s)) nrow(vl_lint(s)) > 0
Adds a rule to the registry vl_lint() runs. A package layered on vellum can
register rules of its own — vellum supplies the geometric ones (a mark drawn
off-canvas, text too small to read), a grammar layer can add semantic ones (a
scale with a single level, a legend with forty entries) — and both come back
from one vl_lint() call.
vl_lint_rule( name, fn, description = "", kinds = NULL, needs_pixels = FALSE, tags = character() ) vl_lint_rules()vl_lint_rule( name, fn, description = "", kinds = NULL, needs_pixels = FALSE, tags = character() ) vl_lint_rules()
name |
Short rule id, e.g. |
fn |
A function of
|
description |
One line, shown by |
kinds |
Node kinds the rule looks at, e.g. |
needs_pixels |
Whether the rule renders the scene. Reported by
|
tags |
Free-form labels for grouping rules, e.g. |
Invisibly, name.
vl_lint_rules(): a data frame of registered rules, with the
kinds a rule looks at, whether it renders the scene, and its tags.
vl_lint_rule("no_hexagons", function(scene, nodes, ctx) { hits <- nodes[nodes$kind == "hexagon", , drop = FALSE] if (!nrow(hits)) return(NULL) vl_lint_finding("no_hexagons", "note", hits, "hexagons are banned here") }, "example rule", kinds = "hexagon")vl_lint_rule("no_hexagons", function(scene, nodes, ctx) { hits <- nodes[nodes$kind == "hexagon", , drop = FALSE] if (!nrow(hits)) return(NULL) vl_lint_finding("no_hexagons", "note", hits, "hexagons are banned here") }, "example rule", kinds = "hexagon")
Ranks a scene's keyed elements by their distance to a point — measuring to the actual geometry, not to a bounding box.
vl_nearest(scene, x, y, units = c("npc", "px"), n = 1L, max_dist = Inf)vl_nearest(scene, x, y, units = c("npc", "px"), n = 1L, max_dist = Inf)
scene |
A |
x, y
|
The probe point. |
units |
|
n |
How many nearest elements to return. |
max_dist |
Ignore anything further than this many device pixels. |
A data frame of key, kind and dist (device px), nearest first.
Zero rows if nothing is in range. Only keyed elements are considered —
an unkeyed mark is not addressable, so reporting it would be no use.
For a round or upright mark the two agree. For anything diagonal or thin they do not, and the difference is not subtle: a line from the bottom-left of a panel to the top-right has a bounding box covering the entire panel, so a box-based "what is nearest" reports it from anywhere in the plot. That is why a host holding only boxes has to exclude such marks from hover rather than rank them wrongly.
Distances are: to the disc for round marks (zero inside), to the rectangle for rects (zero inside), perpendicular to the segment for segments, to the nearest edge for open polylines, and zero anywhere inside a closed polygon or path — so clicking the middle of a choropleth region hits that region.
Marks vellum cannot describe more precisely than a box — labels, rounded rects — fall back to their box, which for a label is a fair description of the target anyway.
A browser cannot call back into R on every mouse move. Use
element_geometry() instead: it returns the same true geometry once, and the
client computes distances locally. vl_nearest() is for hosts that can ask
— a Shiny app, a test, a script.
element_geometry() for the geometry itself, scene_model() for
bounding boxes, hit_test() for "which grob is under this pixel".
s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(segments_grob(0.1, 0.1, 0.9, 0.9, key = "diagonal")) |> draw(points_grob(0.8, 0.2, key = "corner")) # Near the corner the diagonal's *bounding box* also contains this point, # but its geometry is far away. vl_nearest(s, 0.8, 0.2)s <- vl_scene(4, 3, dpi = 96, bg = "white") |> draw(segments_grob(0.1, 0.1, 0.9, 0.9, key = "diagonal")) |> draw(points_grob(0.8, 0.2, key = "corner")) # Near the corner the diagonal's *bounding box* also contains this point, # but its geometry is far away. vl_nearest(s, 0.8, 0.2)
Combine two shapes as geometry: union, intersection, difference or exclusive-or over their closed rings.
vl_path_op( a, b, op = c("union", "intersect", "difference", "xor"), rule = c("nonzero", "evenodd"), gp = vl_gpar(), name = NULL, vp = NULL, role = NULL )vl_path_op( a, b, op = c("union", "intersect", "difference", "xor"), rule = c("nonzero", "evenodd"), gp = vl_gpar(), name = NULL, vp = NULL, role = NULL )
a, b
|
The operands: a |
op |
One of |
rule |
Fill rule for interpreting the inputs: |
gp, name, vp, role
|
Passed to the returned |
A path_grob() with rule = "winding". An empty result (a
disjoint intersection, say) gives a grob with no points, which draws
nothing.
vellum can already clip one shape by another at render time, and for simply
showing an intersection that is often enough. A boolean result is different
in kind: it is an ordinary path, so it can be measured, hit-tested,
simplified, filled with a gradient, stroked, exported as <path> data, and
fed into another boolean. A mask can do none of those, rasterises, and
degrades on some PDF paths.
rule says how to interpret the inputs — whether a ring inside another
ring is a hole ("evenodd") or a separate island ("nonzero"). It must match
the rule the operands were drawn with, or the answer will be correct for a
shape you did not mean.
The result always uses the non-zero rule: holes come back wound opposite
to their outer ring, which is how the returned path_grob() is set up.
Operands must be in a single coordinate space — one unit, no offsets — since a boolean has to be computed somewhere, and mixed units have no common space.
sq <- function(x0, y0, s = 0.4) { list(x = c(x0, x0 + s, x0 + s, x0), y = c(y0, y0, y0 + s, y0 + s)) } a <- sq(0.2, 0.3) b <- sq(0.45, 0.4) vl_scene(3, 2, dpi = 96, bg = "white") |> draw(vl_path_op(a, b, "union", gp = vl_gpar(fill = "#DCE7F5", col = "steelblue")))sq <- function(x0, y0, s = 0.4) { list(x = c(x0, x0 + s, x0 + s, x0), y = c(y0, y0, y0 + s, y0 + s)) } a <- sq(0.2, 0.3) b <- sq(0.45, 0.4) vl_scene(3, 2, dpi = 96, bg = "white") |> draw(vl_path_op(a, b, "union", gp = vl_gpar(fill = "#DCE7F5", col = "steelblue")))
Create a pattern that fills a shape by tiling a grob. The grob is drawn once
into a tile occupying the unit square (0..1 npc), then repeated across a cell
of size width x height (in units) anchored at (x, y). Like gradients,
the cell geometry is resolved against the viewport at draw time.
vl_pattern( grob, width = 0.1, height = 0.1, x = 0.5, y = 0.5, units = "npc", extend = "repeat" )vl_pattern( grob, width = 0.1, height = 0.1, x = 0.5, y = 0.5, units = "npc", extend = "repeat" )
grob |
A grob, or a list of grobs, drawn into the tile (their |
width, height
|
Size of one tile cell (default |
x, y
|
Cell centre (default centred). |
units |
Coordinate system for the geometry; see |
extend |
Tiling mode: |
The tile is rendered to a raster image (sized from width/height at the
scene's resolution) and embedded on every backend: PNG raster, SVG <image>
in a <pattern>, and a PDF tiling pattern with the tile as an embedded image
XObject. Only a degenerate tile or cell size fails, leaving the shape unfilled
with a degrade warning.
A vellum_pattern object, suitable for vl_gpar(fill = ...).
dots <- circle_grob(r = 0.25, gp = vl_gpar(fill = "white", col = NA)) vl_pattern(dots, width = 0.08, height = 0.08)dots <- circle_grob(r = 0.25, gp = vl_gpar(fill = "white", col = NA)) vl_pattern(dots, width = 0.08, height = 0.08)
vl_place() solves label collisions over a scene's resolved geometry and
reports the displacement each label needs. vl_repel() applies that solution
and returns a new scene.
vl_place( scene, labels = NULL, avoid = NULL, padding = 1, max_shift = 10, max_iter = 200, pull = 0.1 ) vl_repel( scene, labels = NULL, avoid = NULL, padding = 1, max_shift = 10, max_iter = 200, pull = 0.1 )vl_place( scene, labels = NULL, avoid = NULL, padding = 1, max_shift = 10, max_iter = 200, pull = 0.1 ) vl_repel( scene, labels = NULL, avoid = NULL, padding = 1, max_shift = 10, max_iter = 200, pull = 0.1 )
scene |
A |
labels |
Names of the grobs to move. |
avoid |
Names of the grobs to treat as obstacles. |
padding |
Clear space to keep around each label, in millimetres. |
max_shift |
The furthest a label may travel from its anchor, in millimetres. Labels needing more are left at their best position and reported as unresolved. |
max_iter |
Iteration cap for the relaxation. |
pull |
Strength of the spring back to the anchor, in |
vl_place(): a data frame with one row per label — name, index
(which element of that grob), the original device box x0/y0/x1/y1,
the shift dx/dy in millimetres, and resolved (did it end up clear).
vl_repel(): a new scene.
Repulsion is a geometry problem over boxes: where things ended up, how big they are, and what they must not touch. It carries no data semantics, so every layer above vellum would otherwise reimplement it — and reimplement it against numbers it can only get by rendering first.
Because the solve happens in device pixels and the answer is applied as an
absolute millimetre offset on top of each label's existing coordinate, it does
not care what produced the position. A label anchored in native units inside
a polar viewport, a facet panel, or a warped coordinate system moves by the
same mechanism as one in npc on the page, and panels are all solved together
rather than one at a time.
It moves labels; it does not choose which to drop, shrink or abbreviate —
those are editorial decisions belonging to the layer that knows what the
labels mean. Labels that cannot be separated within max_shift are reported
with resolved = FALSE rather than silently piled up or quietly deleted.
vl_empty_region() for placing one thing in the gap rather than
moving many things apart.
set.seed(1) n <- 12 s <- vl_scene(5, 3, dpi = 96, bg = "white") |> draw(points_grob(runif(n), runif(n), gp = vl_gpar(fill = "steelblue", col = NA), name = "pts")) |> draw(text_grob(paste0("item ", seq_len(n)), x = runif(n), y = runif(n), gp = vl_gpar(fontsize = 9), name = "lab")) head(vl_place(s))set.seed(1) n <- 12 s <- vl_scene(5, 3, dpi = 96, bg = "white") |> draw(points_grob(runif(n), runif(n), gp = vl_gpar(fill = "steelblue", col = NA), name = "pts")) |> draw(text_grob(paste0("item ", seq_len(n)), x = runif(n), y = runif(n), gp = vl_gpar(fontsize = 9), name = "lab")) head(vl_place(s))
Interpolate between a set of compiled keyframe scenes and encode the in-between
frames to an animated image, in one parallel, streaming pass in the Rust
backend. This is the low-level engine a grammar layer (e.g. vellumplot's
animate()) drives: the caller supplies the K keyframe scenes and a
per-frame schedule — for each output frame, which adjacent keyframe pair to
interpolate and the eased fraction between them — and this renders and encodes
every frame.
vl_render_animation( keyframes, seg, frac, path, format = c("gif", "apng", "svg", "frames"), fps = 25, gif_speed = 1, gif_dither = TRUE, frac_col = NULL, frac_size = NULL, frac_alpha = NULL )vl_render_animation( keyframes, seg, frac, path, format = c("gif", "apng", "svg", "frames"), fps = 25, gif_speed = 1, gif_dither = TRUE, frac_col = NULL, frac_size = NULL, frac_alpha = NULL )
keyframes |
A list of at least two scenes (each a |
seg |
Integer vector, one entry per output frame: the 1-based index of
the frame's left keyframe (it is interpolated with keyframe |
frac |
Numeric vector, one per output frame: the eased interpolation
fraction in |
path |
Output path: an image file for |
format |
|
fps |
Frames per second (sets each frame's on-screen duration). |
gif_speed |
GIF only: the NeuQuant palette sample factor, |
gif_dither |
GIF only: apply Floyd–Steinberg dithering (default |
frac_col, frac_size, frac_alpha
|
Optional per-aesthetic schedules, each a
numeric vector the same length as |
The frames are tweened at the scene level: matching primitives interpolate their geometry, their colours (perceptually, in Oklab), and their bounded graphical parameters; discrete attributes snap. Nothing retrains between frames — the keyframes are fixed at author time — so this is non-reactive keyframe animation, not a live/reactive runtime.
GIF is limited to 256 colours per frame, so on a plot (smooth panels,
antialiased marks) it is inherently lossy — gif_speed/gif_dither make it as
clean as that palette allows. For a lossless result use format = "apng".
path, invisibly.
"svg" emits every frame as vector markup, shown in turn by a CSS step
animation. It is resolution-independent, which no raster format is — the same
file is crisp in a slide, on a retina screen and in print.
Its size depends on scene complexity in a way the raster formats' does not, because every frame is emitted in full. Measured on a 30-frame scatter animation, gzipped (which is how a browser will fetch it):
| marks | animated SVG (gzipped) | GIF |
| 20 | 20 KB | 61 KB |
| 200 | 80 KB | 296 KB |
| 2000 | 720 KB | 124 KB |
So it wins clearly on line art — an explanatory animation of a few moving
marks, which is the common case — and loses on a dense scatter, where a raster
format is the right answer. Serve it gzipped (.svgz, or any web server with
compression on); uncompressed it is several times larger again.
It also honours prefers-reduced-motion: a reader who has asked their system
not to animate gets the first frame, held.
frac and its three companions carry one eased fraction per property
class, so a single frame can interpolate different properties at different
points along their transitions. Every drawn property belongs to exactly one
class:
| schedule | drives |
frac |
x/y and all coordinate geometry, widths and heights, angles, path vertices, text rotation, dash phase — and every discrete attribute's halfway snap (lty, lineend, labels, a variant mismatch) |
frac_col |
fill, col, the stroke paint, and per-element colour vectors (hexagon and sector fills) |
frac_size |
lwd, marker size, circle and corner radius, hexagon extent, linemitre |
frac_alpha |
alpha, including the enter/exit fade — a keyed element appearing or leaving fades on this curve
|
Two consequences worth knowing. Easing alpha retimes entrances and exits,
not just explicit opacity changes. And because a discrete attribute flips when
its fraction crosses 0.5, and an eased curve reaches 0.5 at a different
frame than a linear one, frac decides which frame those snaps land on.
Passing all four identical (the default) reproduces single-curve easing exactly — the output is byte-identical to omitting them.
## Not run: grow <- lapply(c(0.1, 0.3, 0.2), function(r) { vl_scene(3, 2) |> draw(circle_grob(r = r, gp = vl_gpar(fill = "tomato"))) }) # 30 frames across the 3 keyframes, held on the last one. seg <- rep(1:2, each = 15) frac <- rep(seq(0, 1, length.out = 15), 2) vl_render_animation(grow, seg, frac, tempfile(fileext = ".gif")) ## End(Not run)## Not run: grow <- lapply(c(0.1, 0.3, 0.2), function(r) { vl_scene(3, 2) |> draw(circle_grob(r = r, gp = vl_gpar(fill = "tomato"))) }) # 30 frames across the 3 keyframes, held on the last one. seg <- rep(1:2, each = 15) frac <- rep(seq(0, 1, length.out = 15), 2) vl_render_animation(grow, seg, frac, tempfile(fileext = ".gif")) ## End(Not run)
vl_scene() creates an empty scene. push() adds a viewport (and descends
into it), draw() adds a grob, pop() ascends. The builder is a linear pipe;
a rendered or edited scene is an immutable value (edit_node() copies on
modify). render() compiles the scene and writes the output.
vl_scene( width = 6, height = 4, dpi = 96, bg = "white", gp = vl_gpar(), xscale = c(0, 1), yscale = c(0, 1), clip = FALSE, title = NULL, desc = NULL ) push(scene, vp) draw(scene, grob) pop(scene, n = 1) render( scene, path, text = c("native", "outline"), debug = FALSE, scale = 1, cvd = "none" )vl_scene( width = 6, height = 4, dpi = 96, bg = "white", gp = vl_gpar(), xscale = c(0, 1), yscale = c(0, 1), clip = FALSE, title = NULL, desc = NULL ) push(scene, vp) draw(scene, grob) pop(scene, n = 1) render( scene, path, text = c("native", "outline"), debug = FALSE, scale = 1, cvd = "none" )
width, height
|
Page size ( |
dpi |
Resolution in dots per inch. |
bg |
Background colour (or |
gp |
Page-level graphical parameters ( |
xscale, yscale
|
Native coordinate range of the root viewport, so
|
clip |
Clip drawing to the page rectangle? |
title, desc
|
Accessibility: an accessible name (a short title) and a
longer description (alt text) for the scene. When either is set, the SVG
backend emits |
scene |
A |
vp |
|
grob |
A grob (see grob). |
n |
Number of viewport levels to ascend. |
path |
Output file path; the format is taken from the extension ( |
text |
For SVG output, how text is written: |
debug |
If |
scale |
Resolution multiplier (default |
cvd |
Simulate a colour-vision deficiency: |
vl_scene(), push(), draw(), pop(): a vellum_scene.
render(): path, invisibly.
s <- vl_scene(width = 4, height = 3) |> push(vl_viewport(xscale = c(0, 10), yscale = c(0, 10))) |> draw(rect_grob(gp = vl_gpar(fill = "grey95", col = "grey50"))) |> draw(lines_grob(x = vl_unit(0:10, "native"), y = vl_unit(0:10, "native"), gp = vl_gpar(col = "steelblue", lwd = 2)))s <- vl_scene(width = 4, height = 3) |> push(vl_viewport(xscale = c(0, 10), yscale = c(0, 10))) |> draw(rect_grob(gp = vl_gpar(fill = "grey95", col = "grey50"))) |> draw(lines_grob(x = vl_unit(0:10, "native"), y = vl_unit(0:10, "native"), gp = vl_gpar(col = "steelblue", lwd = 2)))
Describes a shadow cast by everything drawn inside a vl_viewport(): the
group's own silhouette, tinted, blurred and offset, painted underneath it.
Because it is a group effect, overlapping shapes inside the viewport cast
one shadow together rather than each casting its own onto the others.
vl_shadow(dx = 2, dy = 2, blur = 3, col = "#00000059")vl_shadow(dx = 2, dy = 2, blur = 3, col = "#00000059")
dx, dy
|
Offset in points; positive |
blur |
Blur radius in points. |
col |
Shadow colour; usually a translucent black. |
A vellum_shadow object, for vl_viewport(shadow = ).
vl_scene(3, 2) |> push(vl_viewport(width = 0.6, height = 0.6, shadow = vl_shadow())) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue", col = NA))) |> pop()vl_scene(3, 2) |> push(vl_viewport(width = 0.6, height = 0.6, shadow = vl_shadow())) |> draw(circle_grob(gp = vl_gpar(fill = "steelblue", col = NA))) |> pop()
vl_strwidth() / vl_strheight() return the rendered width/height of each
string, using the same shaping (textshaping/HarfBuzz + systemfonts)
the renderer uses, so measurements match drawn text. Device-independent (does
not need an open scene). Vectorised over label. (Named vl_* to avoid masking
grDevices::strwidth().)
vl_strwidth( label, family = "", fontface = "plain", fontsize = 12, cex = 1, unit = "in", features = NULL ) vl_strheight( label, family = "", fontface = "plain", fontsize = 12, cex = 1, unit = "in", features = NULL )vl_strwidth( label, family = "", fontface = "plain", fontsize = 12, cex = 1, unit = "in", features = NULL ) vl_strheight( label, family = "", fontface = "plain", fontsize = 12, cex = 1, unit = "in", features = NULL )
label |
Character vector of strings to measure, or a rich label from
|
family |
Font family (e.g. |
fontface |
One of |
fontsize |
Font size in points. |
cex |
Multiplier applied to |
unit |
Output unit: one of |
features |
OpenType features as a named vector of four-character tags
(e.g. |
A numeric vector (one per label) of widths/heights in unit.
vl_strwidth(c("short", "a longer label"), fontsize = 14)vl_strwidth(c("short", "a longer label"), fontsize = 14)
vl_svg_path() parses the d attribute of an SVG <path> into rings;
svg_grob() wraps that up as a drawable path_grob().
vl_svg_path(d) svg_grob( d, x = 0.5, y = 0.5, size = vl_unit(10, "mm"), flip_y = TRUE, viewbox = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL )vl_svg_path(d) svg_grob( d, x = 0.5, y = 0.5, size = vl_unit(10, "mm"), flip_y = TRUE, viewbox = NULL, gp = vl_gpar(), name = NULL, vp = NULL, id = NULL, role = NULL )
d |
A character string of SVG path data, or (for |
x, y
|
Centre of the drawn icon. |
size |
Size of the reference box's longer side, as a |
flip_y |
Flip the y axis to convert from SVG's convention. Leave |
viewbox |
The icon's SVG |
gp, name, vp, id, role
|
Passed to the returned |
This is what makes vector icons usable as marks. Icon sets — Font Awesome,
Bootstrap Icons, Lucide, Material — ship one <path d="..."> per glyph, so
d is the unit of exchange, and the result here is real geometry: crisp at
any size, fillable with a gradient, strokable, and exported as <path> data
rather than an embedded bitmap.
vl_svg_path(): a data frame of x, y, id, closed.
svg_grob(): a path_grob().
SVG's y axis points down and vellum's points up, so svg_grob() flips it
by default (flip_y = TRUE) — otherwise every icon arrives upside-down. The
geometry is then scaled so the longer side of its reference box maps to
size, preserving aspect, and centred on x/y. vl_svg_path() returns the
raw parsed coordinates without any of that, for callers doing their own
placement.
Icon sets draw every glyph inside one shared viewBox (commonly
"0 0 24 24") and pad each glyph within it, so a glyph's own ink fills only
part of the box. Sizing to the ink would blow each glyph up to size
individually — a sparse glyph would render far larger than a dense one from
the same set, and a lone icon larger than its nominal box. Pass the set's
viewbox (the four numbers c(xmin, ymin, width, height), or the raw
attribute string "0 0 24 24") and size maps the box instead, so every
glyph keeps its intended relative and absolute size. If you hand svg_grob()
a whole <svg>…</svg> element (see below) its viewBox is used automatically.
With no viewBox at all, sizing falls back to the path's own ink bounds.
The whole d grammar: M/L/H/V/C/S/Q/T/A/Z in absolute and
relative forms, implicit repeated commands, the smooth-curve reflection rules,
and elliptical arcs. Curves are flattened to polylines.
svg_grob() also accepts a whole <svg> element as d: it reads the
<path> geometry and the document viewBox (needs the xml2 package).
Only <path> elements are read — other drawable shapes (<circle>, <rect>,
<line>, …) are not path data and are reported with a warning, not silently
dropped. The rest of SVG is still out of scope: no stylesheets, gradients,
<use>, clip paths, or element transforms — this reads geometry, not
documents.
Malformed data yields whatever parsed before the problem rather than an error: a truncated icon is easier to diagnose than a stack trace.
# A five-pointed star, as an icon set would ship it. star <- "M12 2 L15 9 L22 9.3 L16.5 13.8 L18.5 21 L12 17 L5.5 21 L7.5 13.8 L2 9.3 L9 9 Z" vl_scene(3, 2, dpi = 96, bg = "white") |> draw(svg_grob(star, x = 0.5, y = 0.5, size = vl_unit(15, "mm"), gp = vl_gpar(fill = "#F1C40F", col = "grey30"))) # As per-point markers, which is what raster icons cannot do crisply. set.seed(1) s <- vl_scene(4, 2, dpi = 96, bg = "white") for (i in 1:6) { s <- draw(s, svg_grob(star, x = i / 7, y = runif(1) * 0.6 + 0.2, size = vl_unit(6, "mm"), gp = vl_gpar(fill = "#2C6FA6", col = NA))) } s# A five-pointed star, as an icon set would ship it. star <- "M12 2 L15 9 L22 9.3 L16.5 13.8 L18.5 21 L12 17 L5.5 21 L7.5 13.8 L2 9.3 L9 9 Z" vl_scene(3, 2, dpi = 96, bg = "white") |> draw(svg_grob(star, x = 0.5, y = 0.5, size = vl_unit(15, "mm"), gp = vl_gpar(fill = "#F1C40F", col = "grey30"))) # As per-point markers, which is what raster icons cannot do crisply. set.seed(1) s <- vl_scene(4, 2, dpi = 96, bg = "white") for (i in 1:6) { s <- draw(s, svg_grob(star, x = i / 7, y = runif(1) * 0.6 + 0.2, size = vl_unit(6, "mm"), gp = vl_gpar(fill = "#2C6FA6", col = NA))) } s
A unit is a vectorised (value, unit) pair used for coordinates and sizes.
Each element carries its own unit, so a single unit vector can mix
coordinate systems and a primitive can use different units on the x and y
axes (e.g. x in "native", y in "npc").
vl_unit(values, units = "npc", data = NULL) is_unit(x)vl_unit(values, units = "npc", data = NULL) is_unit(x)
values |
Numeric vector of magnitudes. |
units |
Character vector of unit names, recycled against |
data |
Optional list supplying context for derived units:
|
x |
An object. |
Supported units:
"npc" — normalised parent coordinates (0 = bottom/left, 1 = top/right)
"native" — the enclosing viewport's xscale/yscale
"mm", "cm", "in", "pt" — absolute lengths
"char", "line" — font-relative (need fontsize/lineheight via data)
"strwidth", "strheight" — size of a string (need label via data)
Font- and string-relative units are resolved to absolute millimetres at
construction (text metrics are available device-independently), so a stored
unit only ever holds one of the core backend units.
Arithmetic: + and - combine two units of the same code, or two
absolute units ("mm"/"cm"/"in"/"pt"), which resolve to "mm"
immediately (e.g. vl_unit(10, "mm") + vl_unit(1, "in") is 35.4mm). A position
unit ("npc"/"native") plus an absolute unit forms a compound unit — a
data/panel anchor plus an exact absolute offset — e.g.
vl_unit(1, "native") + vl_unit(2, "mm") is 1native+2mm: it resolves to the
native position shifted right by exactly 2 mm at render, at any scale or
aspect. Mixing two different position bases (e.g. "npc" and "native")
still errors, as it cannot be reduced to one unit. unit * scalar scales the
base value and the offset together.
A unit vector.
vl_unit(1:3, "native") vl_unit(c(0.5, 1), c("npc", "in"))vl_unit(1:3, "native") vl_unit(c(0.5, 1), c("npc", "in"))
A viewport is a rectangular region that establishes its own coordinate
systems (its xscale/yscale for "native" units), optionally rotated,
clipped, and carrying inheritable graphical parameters. Push one onto a scene
with push(). A viewport may define a row/column grid_layout(); child
viewports are then placed into cells via row/col.
vl_viewport( x = 0.5, y = 0.5, width = 1, height = 1, xscale = c(0, 1), yscale = c(0, 1), angle = 0, clip = FALSE, gp = vl_gpar(), layout = NULL, row = NULL, col = NULL, rowspan = 1, colspan = 1, mask = NULL, alpha = NULL, blend = NULL, blur = NULL, shadow = NULL, name = NULL, meta = NULL, pannable = FALSE, cache = FALSE ) grid_layout( widths = vl_unit(1, "null"), heights = vl_unit(1, "null"), respect = FALSE )vl_viewport( x = 0.5, y = 0.5, width = 1, height = 1, xscale = c(0, 1), yscale = c(0, 1), angle = 0, clip = FALSE, gp = vl_gpar(), layout = NULL, row = NULL, col = NULL, rowspan = 1, colspan = 1, mask = NULL, alpha = NULL, blend = NULL, blur = NULL, shadow = NULL, name = NULL, meta = NULL, pannable = FALSE, cache = FALSE ) grid_layout( widths = vl_unit(1, "null"), heights = vl_unit(1, "null"), respect = FALSE )
x, y
|
Centre of the viewport ( |
width, height
|
Size ( |
xscale, yscale
|
Length-2 native coordinate ranges. |
angle |
Rotation in degrees, counter-clockwise about the centre. |
clip |
Clip drawing to this viewport: |
gp |
Inheritable graphical parameters, from |
layout |
An optional |
row, col
|
Cell (1-based) of the parent's layout to place into. |
rowspan, colspan
|
Number of cells to span. |
mask |
An optional mask: a grob (or list of grobs), or an |
alpha |
Optional group opacity in |
blend |
Optional blend mode for compositing the viewport's contents (as an
isolated layer) onto the backdrop below it. One of |
blur |
Optional Gaussian blur radius in points, applied to the
viewport's contents as one isolated layer. Because it is a group effect,
overlapping shapes inside the viewport blur together rather than each
blurring over the others. |
shadow |
Optional drop shadow from |
name |
Optional name (for |
meta |
Optional free-form metadata for this viewport (any R object,
default |
pannable |
Emit this named panel as a clip-stable pannable group
(default |
cache |
Repaint boundary ( |
widths, heights
|
Track sizes as a |
respect |
Logical; if |
A viewport object.
grid_layout(): a layout object.
vl_viewport(xscale = c(0, 10), yscale = c(0, 100))vl_viewport(xscale = c(0, 10), yscale = c(0, 100))
Reports the resolved width and height of a named viewport (or grob) and what
determined them — the layout track it was placed in, or the units of its own
width/height. The layout companion to the visual render(scene, debug = TRUE)
overlay; together they make coordinate debugging first-class rather than an
exercise in archaeology.
why_size(scene, name)why_size(scene, name)
scene |
A |
name |
A node name (set via the |
A vellum_why_size record (a list with name, width_mm,
height_mm, and determined_by), printed legibly.
s <- vl_scene(4, 3) |> push(vl_viewport(name = "panel", width = vl_unit(2, "in"), height = vl_unit(1, "in"))) why_size(s, "panel")s <- vl_scene(4, 3) |> push(vl_viewport(name = "panel", width = vl_unit(2, "in"), height = vl_unit(1, "in"))) why_size(s, "panel")