vellum is the backend for a small
ecosystem: vellumplot compiles a
grammar of graphics into a vellum scene, and vellumwidget turns a
rendered scene into an interactive HTML widget. Both bind to a narrow
seam: scene_model(), scene_svg(), and the
per-element metadata that rides on grobs. Because the three packages are
version-locked and co-released, a change to that seam can break the
layers above it silently.
This article is the authoritative description of that
seam. If you are building on vellum, or changing vellum’s
output, this is the contract to hold to. (It supersedes the internal
design note _docs/DESIGN-INTERACTIVITY.md, which is kept
only as history.)
vellumplot ──compiles a plot to grobs carrying──▶ key / meta / id / role
│
vellum ──renders──▶ scene_svg() (data-key, data-vellum-*, role attrs)
scene_model() (per-element table: identity + bbox)
│
vellumwidget ──reads scene_svg() + scene_model()──▶ hover / select / brush / link
Everything below the grammar is pure metadata: a static
PNG/SVG render never calls scene_model(), and a grob
without a key/meta still draws exactly as
before. Nothing here changes what is painted.
The batched mark constructors (points_grob(),
circle_grob(), rect_grob(),
segments_grob(), hexagon_grob(),
sector_grob()) take two optional per-element arguments:
key: a character vector of data keys,
one per element (recycled). This is the join key a host uses to tie an
on-screen element back to a datum.meta: a list of free-form per-element
records (recycled), e.g. a tooltip string or field values.Every grob also takes id and role (a single
value per grob) for semantic identity and accessibility.
s <- vl_scene(5, 3, bg = "white") |>
draw(points_grob(
x = c(0.25, 0.5, 0.75),
y = c(0.4, 0.7, 0.4),
size = vl_unit(6, "mm"),
gp = vl_gpar(fill = "#3498db", col = NA),
key = c("a", "b", "c"),
meta = list(
list(tooltip = "first"),
list(tooltip = "second"),
list(tooltip = "third")
)
))
s(Each batched grob carries one graphical style; the grammar layer
above vellum emits one grob per style bucket, so a scatter with three
colours is three points_grob()s. The
key/meta machinery is per element
regardless.)
scene_model(): the per-element tablescene_model() walks the rendered scene and returns a
list of two data frames.
elementsOne row per drawn element of the keyable marks, in paint order:
str(m$elements)
#> 'data.frame': 3 obs. of 14 variables:
#> $ key : chr "a" "b" "c"
#> $ mark : chr "point" "point" "point"
#> $ id : chr NA NA NA
#> $ name : chr NA NA NA
#> $ panel: chr NA NA NA
#> $ x0 : num 97.3 217.3 337.3
#> $ y0 : num 150.1 63.7 150.1
#> $ x1 : num 143 263 383
#> $ y1 : num 195 109 195
#> $ x : num 120 240 360
#> $ y : num 172.8 86.4 172.8
#> $ w : num 45.4 45.4 45.4
#> $ h : num 45.4 45.4 45.4
#> $ meta :List of 3
#> ..$ :List of 1
#> .. ..$ tooltip: chr "first"
#> ..$ :List of 1
#> .. ..$ tooltip: chr "second"
#> ..$ :List of 1
#> .. ..$ tooltip: chr "third"The columns are the contract:
| column | type | meaning |
|---|---|---|
key |
character | the data key (NA if the element was drawn without
one) |
mark |
character | the mark kind (see vocabulary below) |
id |
character | the grob id (NA if unset) |
name |
character | the grob name (NA if unset) |
panel |
character | the enclosing named panel (NA if none) |
x0, y0, x1, y1 |
numeric | device-pixel bounding box |
x, y |
numeric | element centre, (x0+x1)/2, (y0+y1)/2 |
w, h |
numeric | element size, x1-x0, y1-y0 |
meta |
list | the free-form per-element record (list-column) |
The mark vocabulary is a closed set: rect,
point, circle, hexagon,
sector, segment, path,
line, polygon.
Two families produce these rows:
rect,
point, circle, hexagon,
sector, segment) emit one row per
element, always, even when unkeyed (then key is
NA).path,
line, polygon) emit one row per grob,
and only when keyed. An unkeyed path/line/polygon is
geometry-only and does not appear in the table. (A single
sf feature, one polygon or linestring, is exactly one such
element.) These constructors do not take a key argument;
the grammar keys them by setting the grob’s keys slot,
which is how a single sf feature becomes addressable.panelsOne row per named panel (a named viewport becomes an addressable panel), with the bounding box of that panel’s elements:
str(m$panels)
#> 'data.frame': 0 obs. of 5 variables:
#> $ name: chr
#> $ x0 : num
#> $ y0 : num
#> $ x1 : num
#> $ y1 : numColumns: name, x0, y0,
x1, y1.
meta key vocabularyvellum treats meta as opaque: it is a free-form list,
recycled to the element count and carried through untouched; vellum does
not name or validate its keys. The following key names
are conventions the grammar (vellumplot) writes and the
widget (vellumwidget) reads. Documenting them here keeps the three
layers in step; they are not enforced by vellum.
meta key |
written by | read by | purpose |
|---|---|---|---|
tooltip |
vellumplot tooltip= |
vellumwidget | hover tooltip text (falls back to key) |
data_id |
vellumplot data_id= |
(becomes the key) |
the data key for an element |
hover_group |
vellumplot hover_group= |
vellumwidget | co-highlight a group on hover |
hover_color |
vellumplot mark aesthetic | vellumwidget | per-element hover outline colour |
selected_color |
vellumplot mark aesthetic | vellumwidget | per-element selection colour |
legend |
vellumplot legend keying | vellumwidget | the series a mark belongs to |
legend_for |
vellumplot legend keying | vellumwidget | the series a legend swatch drives
("<aes>:<level>") |
If you add a convention, add it here.
scene_svg(): the emitted attributesscene_svg() returns the same scene as an SVG string.
Interactivity rides on SVG attributes; the raster and PDF backends
ignore all of them.
svg <- scene_svg(s)
# the data-key attributes on the three points:
regmatches(svg, gregexpr('data-key="[^"]*"', svg))[[1]]
#> [1] "data-key=\"a\"" "data-key=\"b\"" "data-key=\"c\""Four attribute mechanisms, each emitted only when its source is set:
| attribute | source | scope | set from |
|---|---|---|---|
data-key |
grob key |
per element | points_grob(key=) etc. |
data-vellum-id |
grob id |
per grob/node | *_grob(id=) |
data-vellum-name |
grob name |
per grob/node | *_grob(name=) |
role |
grob role |
per grob/node | *_grob(role=) |
data-vellum-panel |
named viewport | wrapping <g> |
vl_viewport(name=) |
An empty/NULL source emits no
attribute, so a scene that declares no interactivity is
byte-for-byte identical to one built before any of this existed.
A scene carries an optional accessible name and
description, set with
vl_scene(title=, desc=) or
describe(scene, title=, desc=). When present:
<svg role="img" aria-labelledby="…"> with
<title> and <desc> children (the
reliable screen-reader pattern; WCAG 1.1.1);Figure in the structure tree whose Alt is
the description.This is additive: with no title/desc the output is unchanged. The
grammar layer (vellumplot) sets these automatically from
the plot’s title and an auto-generated (or labs(alt=)) alt
text. See vellumplot’s Accessibility
article. Per-element role (above) is a genuine ARIA
role; the interactive widget layer (vellumwidget) adds
keyboard navigation and live-region announcements on top.
These are the properties vellumplot and vellumwidget are entitled to
rely on, and that vellum’s own tests
(tests/testthat/test-contract.R) pin down:
scene_model()$elements is
in draw order, and the SVG emits elements in the same order. A host can
zip the SVG DOM and the table positionally.scene_model() builds the identity columns from the R grob
tree and the geometry columns from the compiled backend, then asserts
they enumerate the same elements: the element counts must match and the
key column must be identical at every position. A mismatch
is a hard error (a compiler bug), never a silent mis-join.key/meta/id/role
never change what is drawn. A render with no keys is identical to the
same scene without the machinery, on every backend.id join key. A grob’s
id surfaces as data-vellum-id in the SVG
and is the join key vellumplot records in its provenance table,
so a widget can map an SVG node to the grammar record that produced
it.If you change any of the following, update this article and the contract tests in the same commit, and expect to co-release vellumplot and vellumwidget:
elements/panels column set or
types,mark vocabulary,meta key vocabulary. ```