The plot is a document

Backend
as_spec() and spec_to_json() serialise a plot to plain data and back; spec_diagnose() validates one against a dataset without throwing; spec_to_vegalite() translates it to another grammar, reporting whatever it cannot carry.

A vellumplot plot is a specification rather than a picture, and that says more than when the drawing happens. The spec is plain data: a nested list of encodings, scales, coordinates, facets, labels, and page size. So it can be written to a file, sent over a wire, diffed, generated by a program, and compiled somewhere else.

p <- vplot(mtcars, width = 6.5, height = 4) |>
  mark_point(x = wt, y = mpg, color = factor(cyl), size = 2.4) |>
  labs(title = "Weight against mileage", x = "weight (1000 lbs)", y = "mpg",
       color = "cylinders")

summary() reports the structure without drawing anything:

summary(p)
#> <PlotSpec> 32x11 (11 columns), page 6.5x4 in
#> 
#> ── layers
#> • mark_point(x = wt, y = mpg, color = factor(cyl)) [size = 2.4]
#> 
#> ── labels
#> • title: Weight against mileage
#> • x: weight (1000 lbs)
#> • y: mpg
#> • color: cylinders

Round-tripping through JSON

spec_to_json() writes it out and spec_from_json() reads it back:

json <- spec_to_json(p)
substr(json, 1, 320) |> cat()
#> {
#>   "$schema": "https://r-vellum.github.io/vellumplot/spec.json",
#>   "version": "1",
#>   "width": 6.5,
#>   "height": 4,
#>   "dpi": 96,
#>   "data": {
#>     "name": "data",
#>     "hash": "33f1aa2876d0ad2a1232bcd25a9a350e",
#>     "nrow": 32,
#>     "columns": [
#>       "mpg",
#>       "cyl",
#>       "disp",
#>       "hp",
#>       "drat",
#>       "wt",
#> 
p2 <- spec_from_json(json)
identical(plot_manifest(p)$spec_hash, plot_manifest(p2)$spec_hash)
#> [1] TRUE

The spec hash is unchanged, so the recompiled plot is the same plot rather than one that resembles it.

What matters more than the round-trip working is what happens when it cannot work. The spec covers the subset of the grammar a portable document can carry faithfully. Anything outside it (a custom transform function, a pattern fill, hand-drawn sketch() geometry, a secondary axis, per-layer data) is refused with an error naming the exact slot, rather than dropped on the floor and discovered later by looking at the picture:

vplot(mtcars) |>
  mark_bar(x = factor(cyl), fill = pattern_stripe()) |>
  as_spec()
#> Error in `.values_to_ir()`:
#> ! Cannot serialize params$fill.
#> ✖ a vellum_pattern value is not serializable (paint/pattern/function/opaque
#>   object).
#> ℹ The spec serializer supports a documented subset; see
#>   `?vellumplot::as_spec()`.

Small data is inlined. Larger data is stored by reference (name, content hash, column schema), and from_spec(spec, data = ) supplies it back at compile time.

Checking a spec before compiling it

spec_diagnose() validates a spec against a dataset and returns a verdict instead of throwing. Every referenced field is checked against the data, then the spec is dry-run compiled:

broken <- sub('"field": "wt"', '"field": "weihgt"', json, fixed = TRUE)
d <- spec_diagnose(broken, data = mtcars)
d$ok
#> [1] FALSE
str(d$diagnostics, max.level = 2)
#> List of 1
#>  $ :List of 4
#>   ..$ severity: chr "error"
#>   ..$ field   : chr "weihgt"
#>   ..$ message : chr "Unknown field 'weihgt'."
#>   ..$ hint    : chr "Available fields: mpg, cyl, disp, hp, drat, wt, qsec, vs, am, gear, carb."

A misspelled field comes back as a record giving the severity, the offending field, and the fields that do exist, rather than as a traceback. Whatever wrote the spec, a person or a program, gets told what to fix. vplot_from_spec() is the strict form, raising a classed error that carries the same diagnostics.

Handing it to another grammar

Vega-Lite is also a layered grammar of graphics, so most of a spec maps across directly. This plot does not translate cleanly, and the export says so:

vl <- spec_to_vegalite(p)
#> Warning: Vega-Lite export dropped 1 unsupported feature:
#> ✖ expression channel 'factor(cyl)'
vl$mark
#> $type
#> [1] "point"

A Vega-Lite field is a column name, and color = factor(cyl) is an R expression evaluated at compile time, so there is nothing on the other side to point the channel at. Materialise the expression as a column and the same export goes through without a word:

cars <- transform(mtcars, cyl = factor(cyl))

vl2 <- vplot(cars, width = 6.5, height = 4) |>
  mark_point(x = wt, y = mpg, color = cyl, size = 2.4) |>
  spec_to_vegalite()

str(vl2$encoding, max.level = 2)
#> List of 4
#>  $ x    :List of 1
#>   ..$ field: chr "wt"
#>  $ y    :List of 1
#>   ..$ field: chr "mpg"
#>  $ color:List of 1
#>   ..$ field: chr "cyl"
#>  $ size :List of 1
#>   ..$ value: num 2.4

That is the rule at this boundary too: what the bridge cannot express is reported rather than quietly dropped. Some things have no equivalent at all to materialise. A polar coordinate system is one:

vplot(mtcars) |>
  mark_bar(x = factor(cyl)) |>
  coord_polar() |>
  spec_to_vegalite() |>
  names()
#> Warning: Vega-Lite export dropped 2 unsupported features:
#> ✖ expression channel 'factor(cyl)' and coord 'polar'
#> [1] "$schema"  "data"     "mark"     "encoding"

The spec is data you can inspect, store, and program against, and every place it loses something it says so. See A figure that carries its own provenance for the hashing side of the same substrate.

Back to top