vellumplot 0.9.0.9000 (development version)
Animation
-
ease_aes()can now ease each aesthetic on its own curve. Alongside the positionalease, it takesposition,color(orcolour),sizeandalpha, each naming an easing of its own; anything left unset falls back toease, so an existingease_aes("cubic-in-out")renders exactly as before.# Bars settle into place gently while the fill crossfades at a steady rate. ease_aes("cubic-in-out", color = "linear")The classes cover:
position— x/y and every coordinate-space quantity (bar widths, angles, path vertices, rotation);color— colour and fill;size— size, linewidth and radii;alpha— opacity, including the fade of marks entering or leaving a state, so easingalpharetimes entrances too.positionalso decides when discrete attributes flip, since those snap at the halfway point. x and y share one curve and cannot be split.
Bug fixes
Clearer errors on a few edge cases:
inset()validates its 0-1 fractional bounds (rejecting inverted / out-of-range coordinates that rendered a broken inset);repeat_()with an empty field vector,vgraph()on an empty graph, and a chord diagram with a negative flow now each report the actual problem instead of leaking an internal message.mark_raster()fails clearly on input it cannot draw. A categorical x/y aborted with an opaque'min' not meaningful for factors, and an irregularly-spaced grid (e.g. x = 1, 2, 4) rendered with every cell silently misplaced. It now errors with a message pointing tomark_tile()in both cases; numeric and evenly-spaced date/time grids are unaffected.as_spec()no longer silently drops declarative interactivity or animation. A plot carryingselect_*()/filter_by()/bind_scale(),transition_*(), orinspect_source()was serialized as if that state did not exist (leaving, e.g., acondition()encoding pointing at a vanished selection). These slots — which hold non-serializable tidy-eval quosures — are now refused with the same classedvellumplot_unserializableerror as the other unsupported state, matching the documented contract.Ordered factors and POSIXct time zones now survive a spec round-trip. An ordered factor came back unordered (so
spec_fields()mislabelled it nominal instead of ordinal); a timestamp’s display zone was forced to UTC. Both are now preserved (the instant was always correct).spec_to_vegalite()/spec_from_vegalite()no longer crash off the happy path. Any unmapped mark, aesthetic, layer param, or scale transform (e.g.mark_smooth(), agroupencoding,trans = "reverse") hit a lookup that threwsubscript out of boundsinstead of returningNULL, defeating the bridge’s own fallbacks. The translation now degrades as documented — the unsupported feature is dropped and reported in the warning — rather than aborting.Constant text labels now render.
mark_text(label = "...")(a literal, not a mapped column) drew invisibleNAglyphs; the label is now read from the layer params too. A text mark (mark_text()/mark_sf_label()/mark_node_text()) with nolabelat all now errors clearly instead of drawing blanks.mark_series_label()on a discrete x now errors with a clear message (it labels each series at its largest x, which a categorical axis has no notion of) instead of a cryptic internal failure.mark_sf_label()skips empty-geometry features (which have no interior point) rather than placing a label atNAcoordinates.guide_legend(override.aes =)is now kept when a colour scale’s legend merges with its shape/size partner.Colour-guide fixes (all in the recently-added
guide_colourbar()/guide_coloursteps()):guide_coloursteps(reverse = TRUE)no longer mislabels its segments (colours and boundary labels now flip together); a horizontal (legend.position = "top"/"bottom") stepped bar orguide_colourbar(barheight =)no longer overflows its reserved row;barwidthnow sets the bar length on a horizontal legend; a binned colour withNAshows its NA swatch underguide_coloursteps(); andlabel.positionis validated as"right"/"left"(the only positions a vertical bar supports).An empty facet cell no longer crashes a mark with an
after_stat()channel. Afacet_grid()layout with an unpopulated cell (or any empty stat input) drawing an aggregating mark (mark_bin2d(),mark_count(),mark_hex(),mark_contour(), amark_histogram(y = after_stat(density)), …) aborted the whole render withobject 'count' not found; the empty layer now renders as a blank panel.mcp_serve()now handles more than one request per process. The stdin connection was left unopened, so eachreadLines()reopened it and the loop saw end-of-file after the first line — a real MCP client died right afterinitialize. The connection is now opened once and held across the loop.from_spec()/vplot_from_spec()now honour supplieddatafor a spec with no data block, instead of dropping it and failing later in compile with an opaque “argument must be coercible to non-negative integer”. A spec that maps fields but has (and is given) no data now errors clearly, naming the missing references.transition_states()filters a layer’s owndata =per keyframe. A layer with its own data (e.g. a per-state annotation) was not subset to the current state, so every keyframe drew all states’ rows at once. A layer whose data lacks the state column still persists across all frames.labs(x = NULL)now blanks the axis title (ggplot2 parity), distinct from omitting the argument. Previously onlylabs(x = "")blanked it.
New features
guide_legend(nested = TRUE)draws a size legend as a proportional-symbol (“bubble”) legend: concentric, bottom-aligned circles with a leader from each to its label, instead of stacked rows. Best with a wide size range (scale_size(range = c(2, 12)) |> ... guides(size = guide_legend(nested = TRUE))).guide_coloursteps()(US aliasguide_colorsteps()) draws a binned colour scale as a segmented colour bar — the bin colours in order, labelled at the numeric boundaries — instead of the default discrete swatches. Takes the samebarwidth/barheight/ticks/label.positiontunables asguide_colourbar():scale_color_binned() |> ... guides(color = guide_coloursteps()).guide_colourbar()(and the US aliasguide_colorbar()) make the continuous colour bar tunable:barwidth/barheight(bar size in mm),ticks/ticks.colour(the break ticks), andlabel.position("right"/"left"labels on a vertical bar), on top of thetitle/reversethatguide_legend()already offered for a colour bar. For exampleguides(color = guide_colourbar(barheight = 60, ticks = FALSE)).mark_sf_label()labels eachsffeature at its interior point (sf::st_point_on_surface(), always inside the polygon), reprojected through the samecoord_sf()CRS asmark_sf()so the labels land on the geometry. Repels crowded labels apart. Layer it over amark_sf()choropleth to name the regions:vplot(nc) |> mark_sf(fill = AREA) |> mark_sf_label(label = NAME).guide_legend()gained anoverride.aesargument (ggplot2 parity): a named list of aesthetics forced on the legend keys only, independent of the plotted data — e.g.guides(color = guide_legend(override.aes = list(size = 5, alpha = 1)))draws large, opaque keys over a scatter of tiny translucent points. Recognised:size,alpha,colour/color,fill,shape,linewidth. The key cell grows to hold an overridden size.scale_x_continuous()/scale_y_continuous()gained anexpandargument controlling axis padding:NULL(default) keeps the usual 5% margin, and a numericc(mult, add)sets a proportion of the data range plus a constant in data units — e.g.expand = c(0, 0)clamps the axis exactly to the data.theme_sketch()now sketch-ifies the plot’s current theme instead of resetting to grey, so a self-contained chart that is deliberately axis-free (vwaffle()/vvenn()/vsankey()/ …) is no longer given back a grid and axes when hand-drawn. An ordinary axis chart is unchanged.Tagged-PDF alt text no longer leaks internal identifiers. A tagged PDF used to make every mark its own
FigurewhoseAltwas the mark’s provenance id (layer-1-line-g1) — or, for amark_text(repel = TRUE)label, its internalvl_placehandle (repel:panel-1-1:2:0) — so a screen reader announced those strings, one per mark. Marks are now tagged as artifacts (role = "presentation"), leaving a singleFigurethat carries theplot_alt()description. Provenance (data-vellum-id) and interactivity (data-key) are unchanged. Needs vellum ≥ 0.6.8 (which keeps the figure for an all-artifact described scene). Reported in #145.
New features
mark_outlier_label()— label only the outliers, not every point. Keeps the rows whoseyis an outlier (Tukey’s IQR rule by default, ormethod = "sd", detected per colour/fill group) and labels just those, repelled apart. Map alabelto name each; with none mapped, the outlier’syvalue is the label.mark_series_label()— direct labels: name each colour series at its line end instead of in a legend (the direct-labels idiom). Maps the samex/y/coloras the lines; the label text and colour come from the series, and the labels are pushed apart with the same repel solver asmark_text(). Give the panel a little x-room (xlim()) and drop the legend (guides(color = "none")).mark_signif()— significance brackets (the ggsignif / ggpubr idiom): run a pairwise test (wilcox.testort.test) betweenxgroups and draw a bracket with its p-value (or stars) over each comparison. Brackets stack above the data and the y-axis expands to fit them. Add on top of amark_boxplot()/mark_violin().scale_color_steps()/scale_fill_steps()— ggplot2-parity aliases of the binned colour scales (a continuous variable cut into steps).Adaptive knit output. When a plot is knitted (Quarto / R Markdown) to an HTML target it is now emitted as a crisp, selectable, resolution-independent inline SVG; other targets (LaTeX / Word) keep the default device render. One plot object, the best format per output — no conditional code.
plot_data_uri()encodes a plot as a self-containeddata:URI (vector SVG by default, orformat = "png") — ready for an HTML<img src>, a Markdown image, an email, or agttable’s non-HTML backends.Three more complete themes for ggplot2 parity:
theme_light(),theme_dark(), andtheme_linedraw().Named palette scale constructors (thin wrappers over
palette =, easing migration):scale_*_viridis_c()/_d()(the viridis maps — viridis/plasma/inferno/cividis/rocket/mako),scale_*_brewer()(discrete) andscale_*_distiller()(continuous) ColorBrewer-style palettes, andscale_*_gradientn()for an arbitrary n-stop colour ramp.scale_color_continuous()/scale_fill_continuous()gained alimits =argument (the direct form oflims(color = ...)).The named graph-layout registry gained
gem,graphopt,dh, andlgl(igraph force-directed layouts, now reachable by name).vwaffle()— a self-contained waffle chart (square pie): a grid of cells coloured by category, each category taking a share of the cells proportional to its count (largest-remainder allocation, so the cells sum exactly). Works from avaluecolumn or raw row counts.position_sina()(andposition = "sina") spreads a category’s points along x by a quasirandom offset scaled to the local y-density, so the cloud traces the distribution’s shape (ggforce’s sina).mark_raincloud()composes a raincloud — a one-sided density cloud (mark_halfeye()) with the raw observations as sina “rain” below it — in one call, forwarding anycolor/fillmapping to both layers.-
New reference and summary marks (ggplot2 parity):
-
mark_abline()— a sloped reference liney = slope * x + interceptacross the panel (slope/interceptmay be vectors for several lines). -
mark_function()— a curvey = fun(x)sampled over the panel’s x range, e.g.mark_function(fun = dnorm)over a density histogram. -
mark_pointrange()/mark_crossbar()— identity summary marks (a point or a box withymin/ymax, the non-aggregating counterparts ofmark_interval()). -
mark_count()— collapse coincident(x, y)points to one bubble sized by overlap count (geom_count()/stat_sum());sizedefaults toafter_stat(n).
-
Internal
- Performance and cleanup with no change to rendered output: the boxplot / violin / half-eye emitters group rows by category once instead of rescanning per level; the
sfpoint path and the circle-pack / sunburst hierarchy layouts drop quadratic accumulation and repeated ancestor walks; the horizontal colour-bar computes each break position once.
Bug fixes
Interop round-trip fidelity: an integer stat parameter (e.g.
mark_histogram(bins = 20L)) now survivesas_spec()/from_spec()and JSON as an integer instead of silently widening to a double (which changed the spec hash). And a Vega-Lite export where two aesthetics map onto one channel (e.g.colorandfill) now reports the dropped encoding rather than silently overwriting it.Clearer, earlier input validation across several surfaces:
scale_*_binned()rejects a non-positive or non-scalarnand a non-scalarstyle; a numeric scalerangerejects a non-finite bound;coord_fixed(ratio=),coord_polar(start=), andcoord_radial(start=/end=)reject non-scalar or non-finite values; the pattern constructors reject a non-positivespacing/size/linewidth;mark_rug(sides=)and a rollingwindow’skare validated; and an MCPtools/callwith no tool name returns a clean error. Each previously failed late with an opaque message (or, forset_mask(region=), silently ignored the argument — now rejected).A discrete position axis no longer draws spurious minor gridlines between and outside its categories.
Legends with mixed titled and untitled guides no longer misalign: a guide with no title reserves no title band.
An
NAin a facet variable now consistently drops the row (matching ggplot2) rather than creating a spurious"NA"panel for character/multi-variable facets.Stacked bars/areas tolerate an
NAheight (dropped from the stack) instead of poisoning the whole group’s cumulative total.A non-finite scale break no longer anchors a stray gridline, tick, or axis label.
Serializing a preset theme carrying an
element_*()override now warns that the customisation is dropped (previously suppressed whenever a preset was set).clip_to()rejects a region vertex whosegroupisNA(it was silently dropped, deforming the clip).Several marks no longer silently ignore aesthetics they document:
alphanow applies tomark_boxplot(),mark_errorbar(),mark_linerange(),mark_rule(),mark_halfeye()/mark_interval(), and themark_smooth()band;mark_smooth()honourslinewidthfor its fitted line; and the text marks (mark_text(),mark_label(),mark_text_path()) honour a mappedsize(trained through a size scale, as points already were).facet_grid()no longer aborts when a row/column combination has no data: a structurally-empty panel (e.g. a histogram cell with no observations) now renders blank instead of killing the whole plot.One-sided continuous limits work as in ggplot2:
ylim(0, NA)/scale_*_continuous(limits = c(NA, hi))now pin the supplied end and train the open end from the data, rather than silently collapsing the axis.Numeric variables drawn on a discrete axis (e.g.
mark_bar(x = a_number)) order their categories numerically (1, 2, 10) instead of lexicographically (1, 10, 2).Overlaying two layers on one discrete axis, one mapping a
factorand one acharacter, no longer drops or mislabels categories.A pie / donut (
mark_pie(),mark_donut()) with a constant or shortxno longer errors.mark_edge_bundle()andmark_flow_map()now accept their documentedeffects =argument (glow / shadow / outline) instead of rejecting every effect.layout = "dendrogram"reports a clear “needs a connected tree” error on a disconnected graph instead of an opaque internal failure.vtable()renders a blank cell for a too-short or empty sparkline series instead of aborting the whole table.Non-looping
transition_reveal()/transition_time()animations now reach their final keyframe instead of stopping roughly one frame short.Faceting on a column whose name is not a syntactic R name (e.g.
"Miles per Gallon", common after a Vega-Lite import) round-trips throughas_spec()/from_spec()instead of crashing.One variable colouring both nodes and edges now draws a single legend. When a node/text colour and an edge colour resolve to the same scale — same title, levels, and palette, as in
vdendrogram(k =)— the redundant edge guide folds into the node colour guide instead of drawing a second, identical legend beside it. Genuinely different node and edge colour scales still keep their own guides (#94).Requires vellum >= 0.6.8, and
plot_lint()gained everything the linter gained there: 13 new rules, two of which vellumplot could not have had on its own — colours a colour-blind reader cannot tell apart, and characters no font on the running machine can draw. Everyvellum::vl_lint()argument now comes through —excludeto accept a finding you have already judged,cvdto pick which deficiency to check,rulesto run one rule alone, andmin_text_ptfor a print-resolution legibility floor.Breaking, minor:
plot_lint()’s thresholds must be named....now sits afterx, so the optional arguments follow it —plot_lint(p, 5)used to meanmin_text_px = 5and now fails, because the5reaches the engine as a rule name. Writeplot_lint(p, min_text_px = 5). This is the usual convention for arguments after..., and it is what makescvd =/exclude =reach the engine.Findings carry the box they refer to, so
vellum::vl_lint_overlay()can draw the report onto the plot rather than describing it, andvellum::vl_lint_assert()can fail a test suite on it.plot_lint()therefore returns four more columns (x0,y0,x1,y1); a grammar finding is about a scale rather than a node, so its box isNA.-
The grammar rules are registered in the engine’s rule registry instead of being stitched on afterwards.
vellum::vl_lint_rules()listssingle_level_scaleandlegend_overflowwith agrammartag,rules =selects them, and a plainvellum::vl_lint()of a compiled plot now reports encoding problems alongside geometric ones —plot_lint()is a thin wrapper rather than a second implementation.A registry rule is handed the resolved scene, which is not enough for a rule about the encoding, so a compiled scene carries a summary of its trained scales: the
kindandlevelsof the legend-bearing ones, a couple of kilobytes, computed where the compile had already built them. Deliberately a summary and not the plot spec, which for 50,000 rows runs past a megabyte and would keep the data alive for as long as anyone held the scene. It leaves the scene’s hash, serialised form and pixels untouched. A composition or table carries none — there is no one set of scales to report on — so those get the geometric findings only (#147). Requires vellum >= 0.6.7, which fixes animated SVG output: duplicate
<defs>ids across frames made every frame’s clip resolve to the first frame’s — hidden for all but one frame of the cycle, and a hidden<clipPath>child clips everything away — soanim_save(".svg")blinked once and then showed an empty panel. The same release also fixes frames playing in reverse.mark_contour_filled()closes bands that leave the grid. A density band whose contour exits the estimation grid was closed with a straight chord back to its start, slicing triangular wedges across the panel (e.g.mark_contour_filled(x = eruptions, y = waiting)onfaithful). Open contours are now closed along the grid boundary, so each band fills the correct region.mark_scalebar()labels read left-to-right in every corner. With a right-anchoredposition("bottomright"/"topright") the0and distance labels were mirrored, putting the distance on the left. They now always read0at the left end and the distance at the right.mark_sf()no longer paints a constantcoloras the fill. With both a constantfilland a constantcolor, the polygon fill took the stroke colour and the outline vanished. Fill and stroke are now resolved as the distinct channels they are:fillfills,colorstrokes.
vellumplot 0.9.0
First tagged release. Everything below is included.
Merged choropleth regions.
mark_sf(merge = TRUE)dissolves adjacent features that share a fill into one region, so the internal borders between same-valued features disappear and each region is a single crisp path — no double-stroked seams, and exact in PDF. The union is real boolean geometry (vellum::vl_path_op()), holes and multipart features preserved. It is ignored on an interactive layer, where per-feature keys must survive.Rotated and wrapping axis tick labels.
axis.text.x/axis.text.ynow honour anangle—theme(axis.text.x = element_text(angle = 45))slants the labels and the gutter reserves the rotated height (an explicithjust/vjustsets the anchor; otherwise the end of the run is pinned at the tick). When the labels are left horizontal, a long discrete label that would overrun its tick spacing wraps onto multiple lines instead of colliding with its neighbour, and the label row grows to fit — the per-tick companion of the title/subtitle/ caption wrapping already shipped. Labels that already fit are unchanged.Multi-page PDFs and parallel batch export.
pdf_pages()writes several plots into one PDF (a report or slide deck), one plot per page — pages may differ in size and each keeps its accessibility tags — or splits a single faceted plot into one page per facet cell.render_all()renders a list of plots to separate files across CPU cores (small multiples, batch export); it is byte-identical to rendering them one by one and parallelises via process forks on macOS/Linux (sequential on Windows). Both build on the engine’svellum::pdf_pages()/vellum::render_all().Real blur effects;
glow()/shadow()now work on text.glow()andshadow()are drawn with the engine’s real Gaussian blur (vellum::vl_viewport(blur=)) — one softened, composited layer instead of a stack of N widened low-opacity copies. It is cheaper and smoother, and it lifts the old restriction that text could not glow:mark_text()/mark_label()now take aneffects =argument and acceptglow()andshadow()(a sharpoutline()still needs a glyph outline the text primitive can’t stroke).outline(),motion(), andecho()are unchanged. Theglow()layers/blendarguments are kept for the neon look (opacity + composite) but no longer stack copies.-
Relative text sizing and tabular figures.
element_text(cex = )sets a size multiplier that cascades through the theme (a base size scaled, rather than an absolute size at every call site). Axis tick labels are now set with tabular figures (tnum), so digit columns keep a constant width and stop jittering from tick to tick.Requires vellum >= 0.6.4.
Venn / Euler diagrams:
vvenn(). Draws 2 or 3 overlapping sets whose disjoint regions are rendered as solid geometry — computed with the engine’s boolean set operations (vellum::vl_path_op()), so overlapping regions do not alpha-composite and stay crisp, including in PDF where a rasterised mask degrades. Each region is filled by how many elements fall in exactly that combination of sets, and labelled with that count. Input is a named list of member vectors or a data frame of logical membership columns. Requires vellum >= 0.6.4.SVG icon markers:
shape = <svg>. A pointshapecan now be an SVG icon — a pathdstring (what icon sets ship) or a path to a.svgfile — drawn as a crisp vector marker viavellum::svg_grob()instead of a built-in glyph. Pass a literald/file for a constant icon, or mapshapeand givescale_shape(values = )one icon per level (the legend shows the icons); thesizeaesthetic scales them. The vector marker stays sharp at any zoom and in PDF, replacing the ggimage/ggsvg raster-per-point hacks. (Per-icon interactivity viadata_idis not yet wired — the icons render on every backend.) Requires vellum >= 0.6.4.-
An accessibility pass. Four pieces that make a plot legible for colour-blind and low-vision readers, and reproducible across machines:
-
render_plot(cvd = )renders a.pngthrough a colour-vision-deficiency simulation ("protanopia","deuteranopia","tritanopia","achromatopsia") so you can see a palette fail. -
plot_lint()flags the problems a green test suite hides: text below a legibility floor, colour contrast under the WCAG threshold, off-canvas or overlapping labels (geometric rules from the engine), plus grammar-level mistakes — a single-level encoding or a legend too long to read. -
pattern_hatch()adds a crisp vector hatch fill (viavellum::vl_hatch()) that stays sharp in PDF and survives greyscale — a redundant, non-colour encoding, usable as a constantfillor ascale_pattern()value. -
Tagged PDF now marks furniture (gridlines, ticks, panel/plot backgrounds) as decorative artifacts, so a screen reader skips them instead of reading every gridline; the figure-level alt text (
plot_alt()) carries the meaning. -
plot_manifest()fingerprints the resolved fonts andplot_verify()reports a font mismatch as a distinct outcome (fonts_ok) from a data mismatch (data_ok) — a pixel difference from a missing font is a different cause, and fix, than a changed dataset.
Requires vellum >= 0.6.5.
-
mark_contour()traces in the engine;isobandis no longer needed. 2-D contour lines and filled contours are now traced byvellum::vl_contour()(marching squares) instead of theisobandpackage, which is dropped fromSuggests.mark_contour()draws each contour as its own chained polyline (closed loops are closed), andmark_contour_filled()paints the closed rings in level order for the layered filled-density look. A density field still usesMASS::kde2d(); contouring a suppliedzsurface needs nothing extra. Requires vellum >= 0.6.4.Label repulsion works on every panel type.
mark_text(repel = TRUE)/mark_label(repel = TRUE)now go through the engine placement solver (vellum::vl_repel()), which solves in device pixels and applies the answer as an absolute millimetre offset. It is a single post-compile pass — no second compile — and it is coordinate-agnostic: faceted, polar and warped (coord_trans) plots repel correctly, where before they errored with “single cartesian panel only”. Labels in a faceted plot are solved per panel and kept inside it. Theseedargument is now inert (the solver is deterministic) and is kept only for back-compatibility. Requires vellum >= 0.6.4.mark_text_path()— labels that follow a curve. A new mark that sets one label per group along the group’sx/ypath, each glyph rotated to the local tangent — for labelling a contour, an arc, or a trend line directly instead of with a legend. Split runs the usual way (a discretecoloror a distinctlabelstarts a new run);hjustslides the run along the path,offsetlifts it off the baseline. Emits real, selectable<text>in SVG. Glyphs follow the tangent, so a path walked right-to-left sets its label upside-down — reverse the path to flip it. Requires vellum >= 0.6.4.Long plot titles, subtitles and captions wrap instead of overflowing. A title / subtitle / caption longer than the page now wraps onto as many lines as it needs, and the layout reserves the height for them (previously the text ran straight off the right edge). Lines are aligned to the band’s
hjust(left / centre / right), and short single-line labels are unchanged. Wrapping is to the page content width, measured in millimetres at compile time. Requires vellum >= 0.6.4 (which fixesgrobheight()to account for the wrapping when it sizes the band’s track).Keys on more marks. Now that the engine can key lines, polygons and text (vellum 0.6.x), the marks that could not previously be made interactive are:
mark_line()/mark_step()(a series is one hoverable object; a colour mapping gives one keyed line per series),mark_area()/mark_ribbon()(the filled band as one polygon),mark_text()(each data label individually), and the sf polygon / choropleth marks (one keyed feature each). This release also fixesmark_label(), whose rounded background box is now keyed as the label’s hit target — it was inert before — andmark_interval()/mark_halfeye(), whose interval bars now carry the datum key like their slab and centre point already did. Setdata_id=(and optionallytooltip=) on the mark to make it addressable; a mark left without a key stays out ofscene_model(), so a plot’s furniture never becomes phantom interactive elements. Requires vellum >= 0.6.3.Portable plot specs. A plot is now a serializable document:
as_spec()turns aPlotSpecinto a plain nested list andfrom_spec()rebuilds it, withspec_to_json()/spec_from_json()for the JSON wire format andspec_schema()for the bundled JSON Schema. The serializer covers the encoding-level grammar exactly and refuses (with a classedvellumplot_unserializableerror) anything a portable document cannot carry — custom transform functions, paint/pattern fills, sketch geometry, secondary axes — rather than dropping it silently.LLM- / agent-native plotting.
spec_fields()summarises a data frame’s columns and inferred encoding types to ground a model;vplot_from_spec()/spec_diagnose()validate a generated spec against the data and return structured diagnostics (unknown field with a suggestion, compile error) instead of a traceback; andmcp_serve()runs a pure-R Model Context Protocol server (bundled launcher atsystem.file("mcp/server.R")) exposingget_schema/list_fields/render_spectools so an agent generates validated data, never executed code.vplot_ask()is a model-agnostic natural-language convenience over the three.Vega-Lite interoperability.
spec_to_vegalite()andspec_from_vegalite()translate a plot to and from a Vega-Lite specification over a documented subset (marks, encodings, scales,bin/count, faceting, inline data, title), reporting any features they cannot map rather than diverging silently.Click-to-source.
inspect_source()declares (on the plot, not as a host flag) that a host should surface the source data rows behind a clicked or hovered element; it is carried ininteraction_model()and enacted by a host reading the scene’s provenance (provenance_payload()).Self-documenting, reproducible plots.
provenance_join()ties every drawn element to both its source data rows and its device-pixel geometry;provenance_payload()exposes the same as a click-to-source payload for a host; andplot_manifest()+plot_svg(manifest = TRUE)+plot_verify()embed a data fingerprint in an SVG so a figure can be checked against the data it was drawn from.Map decorations.
coord_sf(graticule = TRUE)draws meridians and parallels behind the map; because they are generated in longitude/latitude and reprojected, they curve correctly under a projected CRS (a straight grid would be wrong).mark_scalebar()adds a segmented distance bar that sizes itself from the map’s CRS (units"km"/"m"/"mi"/"ft"), andmark_compass()adds a north arrow; both are pinned to a panel corner viaposition. The scale bar and compass need nosfthemselves, but do require a map coordinate system.Keyframe animation.
transition_states()turns a plot into an animation over the levels of a column;animate()compiles one keyframe scene per state — training the scales once over all states and freezing them, so nothing retrains between frames (non-reactive keyframe animation) — andanim_save()tweens and encodes the in-between frames to a looping GIF, an animated PNG, or an animated SVG (.svg— resolution-independent, honoursprefers-reduced-motion;anim_save()advises a raster format when a.svgscene is dense enough that one would be smaller) in one parallel, streaming pass in vellum’s Rust backend.ease_aes()sets the easing (linear, or a family likecubic/sine/elastic/bouncewith an-in/-out/-in-outdirection). Position, size, alpha and colour interpolate (colour perceptually, in Oklab); discrete attributes snap.transition_time()is the continuous-time variant — it allocates frames in proportion to the time gaps, so unevenly spaced times play at a constant rate. Giving an animated mark adata_idenables per-element enter/exit: elements that appear fade in and elements that leave fade out (matched elements tween as usual); without it a stable element set is assumed.transition_reveal()wipes the plot into view left to right (the “line draws itself” animation) by growing a clip rectangle over a single compile. See the Animation article andinst/examples/28-animation.R. Requires the development version of vellum (vl_render_animation()).
vellumplot 0.8.0
Flow maps:
mark_flow_map(). A one-to-many flow map on avgraph()plot: a singlerootfans out to every destination along smooth, merging branches whose width tracks the flow volume (the Minard / migration-map idiom).type = "spiral"(default) builds an angle-restricted spiral tree from the package;type = "steiner"builds an approximate Steiner tree (additionally needs ). Edgeweightdrives the flow, which is mapped ontowidth_range. Needs the suggested package.Edge bundling:
mark_edge_bundle(). A drop-in alternative tomark_edges()that routes a graph’s edges as bundled curves instead of straight lines, so a dense node-link hairball collapses into a few legible trunks.typeselects the algorithm ("force","divided","stub","path","hammer","mingle") andparamspasses tuning through to the package, which computes the geometry; vellumplot draws the returned paths with the usual edge aesthetics (faint by default so overlapping trunks read as density). Needs the suggested package (>= 1.0.0).Chord diagrams:
vchord(). Wraps a weighted flow (afrom/to/valuelist or a square flow matrix) onto a circle – one arc per node, one ribbon per flow through the centre. Directed: each node’s arc splits into an outgoing then an incoming block, soa -> bandb -> aare distinct ribbons and self-flows loop within a node’s sector.directionshows a ribbon’s direction by fading it from source to target ("gradient", default), stopping it short of the target ("gap"), or"both".sort,gap, andlink_colorechovsankey();mark_chord()is the exported layer.Removed the unused
max_overlapsargument frommark_text(),mark_label(), andmark_node_text(). It was reserved for a never-implemented overlap cap and did nothing; drop it from any call.More robust handling of degenerate and extreme inputs. A single distinct value on an axis under
coord_fixed()/coord_sf()no longer collapses the panel to nothing; a map centred on a pole no longer gets a runaway aspect ratio; a circle-pack of collinear equal-value leaves no longer producesNaNcoordinates; and a pathologically deep hierarchy or dendrogram aborts with a clear “too deep” message instead of overflowing the call stack.mark_line(window = )requires a positive integerk. A zero, negative, or fractional window size now errors clearly instead of silently producing a wrong (or empty) result.A computed-constant
if_falseincondition()is carried into the interaction model. A negative literal (e.g.-0.5) or a computed constant (e.g.grey(0.5)) was previously dropped; only genuine per-row column references are now excluded (and carried per element instead).A faceted layer whose own data lacks the facet variable still draws on every panel, but a genuine evaluation error there is no longer silently swallowed into that fallback.
mark_point(auto = TRUE)no longer datashades under a warped coordinate system. Undercoord_polar()/coord_trans()the >50000-row datashade fallback — which bins in linear data space — would rasterise into the wrong place; it is now skipped and the vector path draws, matching the documented behaviour and the otherautomarks (line/step/segment/edges).A sankey column with too many nodes errors clearly. When a column has more nodes than its inter-node gaps can fit, the node/ribbon heights previously went negative and spilled outside the panel;
vsankey()/mark_sankey()now abort with a message pointing atnode_gap.Aggregating stats drop missing summaries consistently, with a warning. For a value-summarising bar (
mark_bar(y = , fun = )and friends), a category whose summary isNA(an empty group, or a summary of data containingNAwhen the function does not remove it) is now dropped with a warning on both the grouped and ungrouped paths, which previously treatedNAdifferently. A layer mapping bothcolorandfillalso keeps both aligned after aggregation (fillcould previously be left at the wrong length).Clearer errors for malformed scale bounds.
scale_x_continuous(),scale_y_continuous(),scale_size(),scale_size_area(),scale_alpha(),scale_edge_width(), andscale_edge_alpha()now reject alimits(or outputrange) that is not a length-2 vector — and, for the size/alpha/edge scales, a non-numeric one — with a clear message, instead of failing later with a cryptic low-level error (e.g.'length = 2' in coercion to 'logical(1)').scale_size_area()also validatesmax_size.add_marginal()rejects a non-numeric mapping. A factorx/ypreviously slipped through and the marginal was computed over the integer level codes rather than the data; it now errors clearly, as a character column already did.Stricter validation of interactivity and layout arguments.
select_point()/select_interval()reject a non-logicaltoggle/empty(it was silently coerced toFALSE);vgraph()validateswidth/heightlikevsankey()/vhierarchy(); andarea()rejects a non-numeric or non-integer cell index instead of aborting with an opaque “missing value where TRUE/FALSE needed”.mark_text()honourshjust/vjustpassed as a variable. They were only read as constant params, so a value routed through a variable (asvdendrogram()does per direction) silently fell back to centred, letting leaf labels run over the edges..emit_text()now reads them value-first, likeangle.vdendrogram()leaf labels also get a larger default gap.Dendrograms and unrooted trees.
vgraph()now accepts a basehclust/dendrogramand gains a height-aware"dendrogram"layout plus an"unrooted"layout (viagraphlayouts::layout_as_tree_unrooted(), needs graphlayouts >= 1.2.5).mark_edges(routing = "elbow")gainedelbow_at/elbow_axisso the elbow can be the dendrogram bracket (corner at the parent’s level) rather than only the midpoint S-bend.vdendrogram()is a one-line preset: bracket edges, leaf labels,direction, andkto cut the tree and colour clusters. Also fixesmark_node_text(label = )so an explicitlabelmapping overrides the default vertex name (it was previously ignored).vhierarchy()respectsscale_fill_*(). By default nodes are still coloured by their depth-1 branch and lightened with depth, but the branch is now an ordinary discrete fill scale, soscale_fill_manual()/scale_fill_discrete()recolour the branches and alightenargument controls the depth fade (0= flat colour per branch). Mappingfillto a node column instead colours every node by that variable (discrete or continuous, with the matchingscale_fill_*()), with no depth fade.
vellumplot 0.7.0
New
vhierarchy()for space-filling hierarchies — breaking.vhierarchy(id, parent, value, type = )draws a tree four ways from one parent list, switching onlytype:"sunburst"(default),"icicle","treemap", or"circlepack". This replacesvsunburst()—vhierarchy(..., type = "sunburst")reproduces the old radial sunburst, andvsunburst()/mark_sunburst()are removed. Treemaps use a squarified layout; circle-pack is a faithful port of d3’s front-chain packing.mark_hierarchy()is the exported layer.Sunbursts, polar pies, and self-loops render un-mirrored. Requires vellum (>= 0.5.1), which fixes
sector_grob()/loop_grob()to draw in the same y-up frame as every other primitive. Sunburst segment labels now sit on their own wedges (they previously landed on the vertical mirror on unbalanced trees), sunbursts wind clockwise from twelve o’clock as documented, and graph self-loops point into the empty gap between a vertex’s incident edges rather than its mirror image.select_point(group_by=)now links whole groups. A point selection withgroup_by/fieldsgroups elements sharing those column values, so hovering or clicking one mark highlights (andcondition()spotlights) its whole group — the “hover a series, light up the series” behaviour. Implemented by emitting the group as the elementhover_group, so it needs no widget change; a user-declaredhover_groupstill wins. Inert on a static render.Declarative interactivity (new). Interaction is now part of the plot spec rather than something a host is configured to do. Declare a named selection with
select_point()(click/hover) orselect_interval()(brush/lasso), then refer to it fromcondition()(style an aesthetic by selection membership),filter_by()(show only members — point a second view at the same selection for cross-filtering), orbind_scale()(drive another panel’s view, for overview + detail).add_selection()shares a free-standing selection across views;interaction_model()returns the compiled declaration block a host reads. Every node is inert on a static render (a plot with interactions renders byte-for-byte identically to one without), andcondition("s", g, "grey80")trains scales and draws exactly likecolor = g. Enacted byvellumwidget.
vellumplot 0.6.0
Sankey styling options.
vsankey()/mark_sankey()gainshow_values(append each node’s value to its label),flow_color("source","target", or"gradient"— a source-to-target colour fade per ribbon), andnode_width/node_gapto tune the node rectangles.Sankey crossing minimisation.
vsankey()now orders the nodes within each column with the Sugiyama barycenter heuristic to minimise ribbon crossings (previously first-appearance order, which left many avoidable crossings). The reordering is deterministic and pure R. A related fix stacks each node’s ribbon slices to meet the node in the same vertical order as the nodes they connect to, removing the remaining crossings within a fan of ribbons.Sunburst rendering fixes.
vsunburst()now colours wedges by their top-level branch (each branch a distinct hue, lightened with depth) instead of a single colour per ring — sibling branches were previously indistinguishable. It also starts the first wedge at twelve o’clock and winds clockwise, matching the package’s pies/roses (coord_polar) rather than starting at three o’clock counter-clockwise.Sunburst / radial hierarchies:
vsunburst(). A new plot type for part-of-whole hierarchies, from a parent list —id,parent(NAat the root), andvalue(leaf values; internal nodes sum their children). Depth maps to a ring and each node’s angular span is its share of its parent’s, drawn as one batchedsector_grobin an aspect-locked, axis-free square panel (mirroringvsankey()/vgraph()).inner_radiusopens a central hole (a donut/ring sunburst); nodes are coloured by depth.mark_sunburst()is the exported layer. Seevignette("flows-and-hierarchies").Sankey / flow diagrams:
vsankey(). A new plot type for layered flows, built from a flow list — one row per flow withfrom,to, andvalue(the ribbon width). Nodes are the union offrom/to; a node that is both a source and a target makes the diagram multi-stage.vsankey(data, from, to, value)returns a ready, axis-free plot (mirroringvgraph());mark_sankey()is the exported layer it adds. The layout is computed R-side (longest-path layering, value-proportional node heights and ribbon widths, filled Bézier ribbons) and is deterministic. Flows must form a DAG; nodes are coloured from the qualitative palette. Seevignette("flows-and-hierarchies").Uncertainty marks:
mark_halfeye()andmark_interval(). ggdist-style slab + interval marks for sample/posterior input (manyyrows per categoricalx).mark_halfeye()draws a one-sided density slab with a point-interval at its base — the median (orpoint = "mean"), a thick inner and thin outer equal-tailed quantile interval at the.widthprobabilities (defaultc(0.66, 0.95));mark_interval()is the point-interval alone. A natural fit for visualising posterior draws (e.g. from ).coord_radial()andscale_size_area().coord_radial()is a fuller polar system (ggplot2 3.5’s name): besidestheta/start/directionit takesendto sweep only a partial arc (e.g.start = -pi/2, end = pi/2for a semicircular gauge) andinner_radiusfor a donut hole; with the defaults it matchescoord_polar().scale_size_area()maps a value to the marker’s area (value0→ size0), the perceptually honest default for bubble charts, withmax_sizethe size of the largest value.Sankey labels stay on-panel.
vsankey()now reserves horizontal margin for node labels, so the source column’s labels (drawn to their left) and the terminal column’s (drawn to their right) no longer clip at the panel edge.Robustness of the new marks.
vsunburst()/mark_sunburst()now reject a missing or negative leafvaluewith a clear message (instead of a cryptic downstream error), andmark_sunburst()validatesinner_radiuslikevsunburst()andcoord_radial()do.mark_halfeye()/mark_interval()skip a category with fewer than two finite observations (with a warning) rather than drawing an empty interval, and reject awidth =argument (a likely typo for.width) that was previously ignored silently.Consistent argument names for the hole radius / ridge height. The central-hole fraction is now spelled
inner_radiuseverywhere it appears:coord_radial(),vsunburst()/mark_sunburst(), andmark_donut()(washole).mark_ridgeline()’s overlap control is nowheight(wasscale, which collided withmark_halfeye(scale=), a different quantity).Consistent
sketch/blendpassthrough.mark_ecdf(),mark_contour(),mark_contour_filled(),mark_dotplot(),mark_qq(), andmark_qq_line()now accept a per-layersketch =(their emitters already honoured it), andmark_pie()/mark_donut()acceptblend =, matching the rest of the mark surface.Parameterised position adjustments. New
position_nudge(),position_jitter(),position_dodge(),position_dodge2(), andposition_jitterdodge()give a mark’spositiontunable parameters (a bare string like"dodge"still works with the defaults). Adds three adjustments:nudge(shift every element by a constant in data units),dodge2(dodge by the groups actually present at each x, filling the band with apaddinggap — so ragged groupings stay centred), andjitterdodge(jitter points within their dodged slot).position_jitter(width=, height=, seed=)andposition_dodge(width=)expose the previously-fixed jitter/dodge extents. A plot using the old string positions is unchanged.Label repulsion:
mark_text(repel = TRUE)/mark_label(repel = TRUE). Overlapping text labels are moved apart with a force-directed layout (ggrepel-style), each keeping a thin leader line back to its point. Because the plot size is fixed on the spec, the repulsion is resolved exactly against the true rendered panel — a two-pass compile that reads the panel’s device geometry fromvellum::scene_model(), relaxes the label boxes in pixel space, and maps the result back — so it needs no approximation and novellumchange, and is deterministic underseed. Tunable viabox_padding,point_padding,min_segment_length, andseed. Limited to a single cartesian panel for now (facets / composition / polar error clearly).mark_line(window = ): rolling / cumulative / offset transforms. A line can now transform itsyper group (over rows ordered byx) before drawing — movingmean/sum/median/min/maxover a window ofk, runningcumsum/cummean/cummax/cummin,lag/leadshifts, orrank. Pass an op name (window = "mean") or a list (window = list(op = "mean", k = 7, align = "right", partial = TRUE));alignis trailing/leading/centred andpartialfills the edges from the shorter window so the line stays continuous. A plot withoutwindowis unchanged.Diverging colour scales:
scale_color_gradient2()/scale_fill_gradient2(). A three-point ramp (low–mid–high) centred onmidpoint(default0), rescaled about the midpoint (scales::rescale_mid) so the neutral colour sits on the chosen value and each side spans as far as the data reaches — the correct scale for signed / anomaly data. A diverging continuous colorbar also reportsmidpoint+divergingin itscolorbardescriptor, so an interactive host can centre a value-range filter on the neutral value.symlogposition transform.scale_x_continuous(trans = "symlog")(andy) adds a symmetric-log axis: linear through zero, logarithmic in the tails (sign(x) · log10(1 + |x|)), so signed data spanning several orders of magnitude — including zero and negatives, whichlog10cannot show — reads on one axis. Breaks sit at zero and signed powers of ten. The transform name flows into the panelscalesdescriptor like the other transforms.New group-region marks:
mark_ellipse()andmark_hull(). Both enclose a set of(x, y)points in a single region drawn over a scatter — one region per group when acolor/fillis mapped.mark_ellipse()draws a covariance ellipse (type = "t"robust default via , or"norm"/"euclid"), following ggplot2’sstat_ellipse();mark_hull()draws the convex hull. Both are unfilled boundaries by default (map/set afillto shade them) and need at least 3 points per group. The region’s boundary trains the position scales, so an ellipse that bulges past the data is not clipped.mark_smooth()gains real smoothing methods. Beyond"lm", the smooth mark now fits"loess"(local regression,span =),"glm"(with afamilyviamethod.args, e.g. logistic — the fit and its ribbon are back-transformed from the link scale),"gam"(a penalised smooth, defaulty ~ s(x); needs ), and"rq"(quantile regression at a singlemethod.args$tau; needs , line only, no ribbon). The defaultmethod = "auto"picksloessfor small groups (< 1000 points) andgamfor large ones, as in ggplot2. Newformula,span, andmethod.argsarguments;glm/gambands use a normal interval,lm/loessa t-interval. Previouslymark_smooth()errored on any method other than"lm". and are Suggests — a gated method errors clearly if its package is absent.Data panels are emitted as pannable, gridlines tagged. Cartesian data panels (including
coord_flipand a linearcoord_trans) now push apannablevellum viewport, and gridlines carryrole = "grid". This is inert for static rendering but lets an interactive host (vellumwidget) pan/zoom a panel’s marks while its clip + axes stay fixed and hide/redraw gridlines — the groundwork for axis-aware zoom. Polar / nonlinear-coord_transpanels stay non-pannable. Requires the current developmentvellum.Continuous colorbar filter metadata. A continuous
colorscale now attaches each mark’s colour value asfilter_valuein its elementmeta, and acolorbardescriptor (value domain + orientation) to the gradient-bar grob. Together (viavellum::scene_model()) these let a host such asvellumwidgetoverlay an interactive value-range filter on the colorbar. Discrete/binned colour scales are unaffected. No change to rendered output.Panels now carry a
scalesdescriptor for interactive hosts. Each cartesian data panel’s viewport gains ameta$scalesrecord — per axis:type(continuous / log10 / discrete / binned / date / datetime),transform, the data and native domains, tick breaks + labels, andtime_unitfor date/datetime axes. It surfaces viavellum::scene_model()$panels$metaand lets a host (e.g.vellumwidget) map device pixels back to data values — so a brush or a reported view can be expressed in data coordinates, not just pixels. Requiresvellum(>= 0.4.0.9000). Internal: trained position scales now also record theirtransformname and, for date/time axes, atime_unit(previously the date/datetime nature was lost after training). No change to rendered output.
vellumplot 0.5.0
Rich
md()legend titles no longer clip. A legend built from a rich title (scale_color_continuous(name = md("Power (hp m^2^)")),labs(color = md(...))) measured the title as zero width, so the legend reserved no room for it and the drawn title spilled off the page. Titles are now measured through vellum’s rich text path (vl_strwidth()), reserving the space they actually occupy.Secondary axes. Continuous position scales gain a
sec.axisargument fed by the newsec_axis()/dup_axis(): a second set of ticks and labels on the opposite edge (top forx, right fory), computed as a 1:1 monotonic transform of the primary axis — e.g.scale_x_continuous(sec.axis = sec_axis(~ . * 1.8 + 32, name = "°F"))for a unit conversion, ordup_axis()to duplicate an axis on a wide plot. The transform is a formula, function, orscales::transform_*()object. Scoped to the default Cartesian system with shared facet scales; combining it withcoord_flip()/coord_polar()/coord_trans(), free facet scales, oradd_marginal()raises a clear error, and it is not drawn inside a plot composition. A plot without asec.axisis byte-for-byte unchanged.Datashading now covers dense lines and large-graph edges.
mark_line(),mark_step(),mark_segment(), andmark_edges()gain anauto = TRUEswitch (parallel tomark_point(auto = TRUE)): past a row threshold the layer rasterises into a line- / segment-density field viavellum::datashade_lines()/vellum::datashade_segments()instead of emitting one vector per element, so overplotted timeseries stacks and graph “hairballs” render fast and honestly. The fallback is skipped under a warped coordinate system (coord_polar()/coord_trans()), which keeps the vector path. (Area / ribbon datashading is not yet available, pending area-fill support in vellum.)mark_datashade(spread = )exposes vellum’s post-aggregation spreading:NULL(default, raw output), a positive integer for a fixed pixel radius (vellum::spread()), or"auto"for density-adaptive dilation (vellum::dynspread()). Datashaded lines and segments default to"auto"so single-pixel marks stay visible.Continuous and binned colour scales now interpolate perceptually (Oklab) by default. A colour ramp built from a plain colour vector (e.g.
scale_color_gradient(low, high)orscale_color_continuous(palette = c(...))) is blended in the perceptually-uniform Oklab space instead of sRGB, so it no longer passes through muddy, over-dark midtones or drifts in hue — the ramp and its legend colourbar read evenly. Designed perceptual palettes (thebatlowdefault,hcl.colors()names) are already uniform and are unchanged. Setoptions(vellumplot.color.interpolation = "srgb")(or"lab") to restore the old behaviour. Gradient fills opt in per gradient withlinear_gradient(..., interpolation = "oklab")(passed through to vellum).Error bars and line ranges are now interactive.
mark_errorbar()andmark_linerange()thread a declareddata_id/tooltip/hover_groupthrough to their drawn segments, so each bar is keyed to its datum — it appears inscene_model()andplot_provenance()and carries adata-keyin the SVG, ready for hover/click/select in a widget (a bar’s cap segments share the bar’s key).mark_boxplot()already keyed each box by its category; this completes the statistical marks. A mark with no interactivity declared is unchanged.Two more shapes in
scale_shape(). Theshapeaesthetic’s default palette now extends to"triangle_down"(a downward triangle) and"star"(a five-pointed star) after the original six, so a mappedshapecovers up to eight levels without an explicit scale, and both are accepted asscale_shape(values=). Requires the accompanyingvellumdev version. (A constantshape = "star"on a mark already worked once vellum gained the shape.)
vellumplot 0.4.0
New features
Categorical datashading in one call.
mark_datashade()now accepts a mapped discretecolor/fillaesthetic and shades categorically (datashader’scount_cat): each category is aggregated separately and every cell is coloured by the count-weighted average of the category hues it holds, opacity by density — so you see which category dominates where, and where they mix, with a colour legend. Category hues come from the usual discrete colour scale (scale_color_*()apply). This replaces the old idiom of stacking one datashade layer per category withblend = "screen". Newspan/cliparguments clamp the density range (absolute limits or percentiles) so a few extreme cells don’t flatten the rest. Requires the accompanyingvellumdev version.mark_image()draws images at data points. A new mark places a bitmap image (e.g. a country flag or company logo) at each(x, y), replacing the usual point marker.srcis a column of local file paths or one constant path, andsizesets the height in millimetres (the width follows each image’s native aspect ratio). Requires the suggestedmagickpackage (#33).coord_trans()— nonlinear display transforms. Warps the display of one or both position axes after the scale has trained, so gridlines bunch up and straight lines curve while the axis keeps its original data-value labels (unlikescale_*(trans=), which rescales the data and relabels in transformed space). Each ofx/ytakes a transform name ("log10","sqrt","identity") or ascales::transform_*()object, e.g.coord_trans(y = "log10"). Supports the common marks (points, lines, areas/ribbons, bars, tiles, smooths, text, …); segment/interval, boxplot, edges, and raster/datashade marks are not warped yet and raise a clear error undercoord_trans().mark_violin()andmark_ridgeline()no longer clip at the edges. The position scales are now trained to include each mark’s drawn footprint – the full kernel-density support for a violin, and the ridge height (scale) above the top category for a ridgeline – so the tallest ridge and the density tails are no longer cropped by the panel. Explicit axis limits still take precedence.Marginal distributions with
add_marginal(). A new plot modifier draws a density or histogram of the panel’sxvariable along the top edge and of itsyvariable along the right edge, each sharing the scatter’s axis so they line up (the vellumplot analogue ofggExtra::ggMarginal()). Pick the distribution withtype = "density"/"histogram", the edges withsides("t","r","tr"), and the extent withsize;group = TRUEsplits each marginal by a discretecolor/fillmapping in the matching palette. It readsx/yfrom the first plain (identity-stat) layer and reuses the existing density/bin stats, so no axes or legends are duplicated. This version supports a single panel only (an error is raised with facets, a non-Cartesian coordinate system, or a locked aspect ratio).Positional constants now render. A bare literal on a coordinate channel (e.g.
mark_segment(x = i, y = 0, xend = i, yend = value)for a lollipop baseline) is now treated as a constant-valued coordinate rather than a style param, so it populates the layer’s values and trains the position scale. Previouslyy = 0was dropped from scale training, collapsing each segment’s start onto its end (zero-length, invisible), and a segment-only plot errored with “Every layer needs an x and y encoding”. Segment/edge endpoints (xend/yend) now also extend the axis domain, so a segment-only plot derives its range from both ends. Affects the positional channelsx,y,xend,yend,xmin,xmax,ymin,ymax.Faster
mark_sf()on large maps. Non-interactive sf layers now batch all features that share a resolved fill/colour into a single grob per style group (polygons into oneevenoddpath_grob, lines into one NA-separatedlines_grob), instead of one grob per feature, and the layer’s bounding box is computed in a single linear pass rather than by growing coordinate vectors. On a 40k-polygon grid this cut scene compilation from ~24s to ~0.8s. Interactive layers (those declaringdata_id/tooltip/hover_group) are unchanged — they still emit one keyed grob per feature so every feature stays addressable. Note: because a batched polygon group is filled with oneevenoddrule, two same-fill features that overlap would cancel in the overlap region (the same property a self-overlappingMULTIPOLYGONalready has); disjoint features — every real choropleth and coverage map — are unaffected.
Bug fixes
linetypenow works on stroked marks.mark_segment(),mark_linerange(),mark_rule(),mark_errorbar(),mark_rug(),mark_edges(), andmark_contour()(andannotate("segment", ...)) previously ignoredlinetypeand always drew solid lines; they now honour it, consistent withmark_line()(#30).annotate("rect", ...)honours infinite bounds. A bound of-Inf/Inf(e.g.xmin = -Inf, xmax = Inffor a full-width band) now extends the rectangle to the panel edge, matching ggplot2, instead of silently rendering nothing (#29).Continuous labels no longer group thousands by default. A default continuous axis or legend now renders 4-digit-plus values without a grouping separator, so years and IDs read
2010instead of2 010(#27). Pass explicitlabelsto restore grouping.More inputs fail fast. A
designlayout area must be a solid rectangle (andarea()requirest <= b,l <= r); a per-row interactivity value (tooltip/data_id/…) must be length 1 or the data’s row count; andadd_marginal()requires a cleanly numeric column. Each now errors clearly instead of silently mis-rendering.Clearer alt text for grid facets. A
facet_grid()plot’s automatic alt text now distinguishes the row and column variables (e.g. “Faceted by rows cyl, columns am”) instead of running them together.Compositions can be themed.
theme()now accepts a composition (from [concat()] / [hconcat()] / [vconcat()]) and styles its figure-level chrome — the title/subtitle/caption bands, the collected legend, panel spacing, and tags. Previously a composition’s figure chrome always used the default theme. Each sub-plot still carries its own theme.Gradient fills fail clearly on marks that don’t support them. A
fill = linear_gradient(...)is painted bymark_area(),mark_ribbon(), andmark_bar(); on any other mark it now raises a clear error instead of leaking an undefined paint into the renderer.Inputs are validated up front.
theme()now checkspanel.spacing,plot.margin,aspect.ratio, andaxis.ticks.length(and rejectsNA/Infin any numeric setting);vplot()checkswidth/heightthe way it already checkeddpi; a discreteshape/linetypescale with more categories than its palette errors instead of silently reusing a glyph; and a binned colour scale given fewer than two breaks errors instead of crashing incolorRampPalette(). Valid input is unaffected.Legends read consistently. A horizontal (top/bottom) continuous colour legend now draws the
NAkey that the vertical one always did.guide_legend(reverse = TRUE)now actually flips a continuous colourbar (previously a silent no-op) and no longer desyncs a binned colour scale’s boundaries from its swatches. A merged colour+shape legend’s swatches are now tagged for interactive highlight/select. Minor gridlines past the outermost breaks use the local break spacing at each end, so they sit correctly when breaks are unevenly spaced.Numeric facets order numerically. Faceting on a numeric variable now orders panels
1, 2, 10rather than the lexicographic1, 10, 2; multi-variable facets order by each variable’s own type.Log/reversed axes. Bars and areas on a
scale_*(trans = "log10")(or"sqrt") axis draw from the axis floor instead of a degenerate shape (the zero baseline that maps to-Infis clamped into the trained range). A descending limit such asylim(hi, lo)on a bar/area is no longer silently un-reversed by the zero baseline.Grouped histogram density.
after_stat(density)/after_stat(prop)on a grouped histogram or bar now normalizes per group (each group integrates/sums to 1), matching ggplot2, rather than dividing by the grand total.Clearer errors instead of silent surprises or crashes.
coord_trans()rejects a"reverse"transform (a no-op there — usescale_*(trans = "reverse")) and rejects bar/area marks on a nonlinearly transformed axis (a zero baseline has no place on a log display). Histogram / 2-D bin / hexbin stats abort cleanly on all-NAinput instead of a crypticseq()error.mark_raster()and z-surface contours reject a grid with a duplicated cell (previously a silent transparent/NAhole). Composition auto-tags continue past 26 sub-plots (Z,AA,AB, …) instead ofNA.mark_area()now stacks.mark_area(position = ...)was silently ignored; it gains apositionargument ("stack"default, plus"fill"and"identity"), so areas sharing anxwith a mappedfill/colorcombine into a band instead of overlapping from the zero baseline. An area with no fill mapping is unchanged.Mapped aesthetics that were silently dropped now take effect. A mapped
fillonmark_label()colours the label background (previously it always fell back to white); a mapped or constantalphais honoured per category/tick bymark_violin(),mark_ridgeline(),mark_contour(),mark_contour_filled()andmark_rug()(previously collapsed to one value or ignored), andmark_rug()also honours a mappedcolor.British spelling works everywhere.
hover_colour/selected_colourare now recognised as interactivity arguments (previously only the American spelling was), andmark_bin2d()/mark_hex()no longer add a default count fill when a Britishcolour =is supplied.vgraph()warns on a layout-column clash. A vertex/edge attribute namedx/y(orxend/yend) is overwritten by the layout coordinates; that used to happen silently and now emits a warning naming the attribute.
vellumplot 0.3.0
Adopted vellum’s renamed
vl_*graphics primitives (vl_gpar(),vl_unit(),vl_viewport()), which no longer mask grid.NA legend keys for
size/shape. A mappedsizeorshapeaesthetic whose data contains missing values now shows an “NA” key (as the colour scales already did). Also fixes a crash: NA in ashapemapping previously errored (Unknown point shape: NA) — it now draws as a neutral circle.2-D density contours.
mark_contour()draws iso-density contour lines of a 2-D point cloud andmark_contour_filled()fills the bands between them (coloured by level automatically). By default the field is a kernel density estimate (viaMASS::kde2d()); map azaesthetic to contour a supplied surface over a regularx/ygrid instead. Contour tracing uses theisobandpackage (bothisobandandMASSare Suggests).Binned position scales.
scale_x_binned()/scale_y_binned()cut a continuous axis into bins — ticks at the bin boundaries, each datum drawn at its bin centre — reusing the binned-colour classification (style/n, or explicitbreaks).mark_bar()sizes to the bin width.Rich and multi-line text labels.
mark_text()now carries rich labels through to the renderer instead of flattening them: maplabel = md(<expr>)for a per-datum styled label (bold/italic/super-/subscript/colour), and plain labels may contain newlines (\n) to wrap onto stacked lines. Requires vellum’s development version. (mark_label()’s background box does not yet support rich labels.)Accessibility (alt text). Every compiled plot is now an accessible image by default.
plot_alt()returns a plot’s text alternative — an author-written string from the newlabs(alt = ), or a prose summary vellumplot generates from the spec (chart type, x/y/colour/size mappings, observation count, faceting). At the compile seam the plot title becomes the scene’s accessible name and this alt text becomes its description, sorender_plot()output carriesrole="img"+<title>/<desc>in SVG and a taggedFigurewithAltin PDF (via vellum). See the new Accessibility article. Requires vellum’s development version (>= 0.1.1.9000) for the accessible SVG/PDF backend.British spelling.
mark_*(colour = )is now honoured as an alias forcolor(previously the mapping was silently dropped and no colour scale was trained). Addedscale_colour_*()aliases for thescale_color_*()family.Faithful alt text. The auto-generated description now matches what the plot actually renders: it honours
scale_*(name = )axis/legend titles, names the implicit “count” axis of a count bar, and describes a network graph by its node and edge counts instead of its (meaningless) layout x/y axes.
vellumplot 0.2.1
Consumes vellum’s new compound native + mm unit (requires vellum >= 0.1.1).
-
Label nudges:
mark_text()/mark_label()gainnudge_x/nudge_y(in millimetres,+xright /+yup) that shift a label by an exact absolute distance, device-resolved so the nudge is constant regardless of scale or panel aspect. -
Device-exact drop shadows:
shadow()’sx/yoffset is now an absolute distance in millimetres (was a panel-relative npc fraction), so a drop shadow stays the same physical distance and is isotropic on non-square panels. Defaults changed accordingly (a small down-right drop). Replaces the previous npc-fraction workaround.
Device-space dodge (mark_bar) and jitter (mark_point) remain data-space for now; converting them to the compound unit is deferred (it changes existing rendered output and needs snapshot review).
vellumplot 0.2.0
Grammar-breadth release: new scales, mapped aesthetics, legend control, and distribution marks, closing the most conspicuous gaps versus ggplot2.
Scales & axes
-
Date/time scales:
scale_x_date()/scale_x_datetime()/scale_x_time()(andytwins) withdate_breaks(interval strings like"3 months") anddate_labels(strftime formats).Date/POSIXctcolumns still get a sensible date axis automatically. -
New mapped aesthetics:
scale_alpha()(continuous opacity) andscale_linetype()(discrete line types), each drawn per element with its own legend.alphamapped to data now varies opacity (previously constant-only);linetypeapplies tomark_line()/mark_step(). -
Identity scales:
scale_color_identity(),scale_fill_identity(),scale_size_identity(),scale_shape_identity(),scale_alpha_identity(),scale_linetype_identity()— use data values verbatim, no legend. -
Limit shortcuts:
xlim(),ylim(), andlims(). -
Legend control:
guides()withguide = "none"(hide a legend),guide_legend(reverse = TRUE)(reverse key order), andguide_legend(title=).
Marks & stats
-
Distribution marks:
mark_ecdf()(empirical CDF step),mark_rug()(marginal ticks),mark_qq()+mark_qq_line()(quantile-quantile plot). -
Density-shape marks:
mark_violin()(mirrored density per category),mark_ridgeline()(overlapping per-category densities), andmark_dotplot()(binned, stacked dots). Violin and ridgeline carry per-category provenance.
Not yet implemented (still planned)
2-D density / contour (mark_contour/stat_density_2d); position-binned scales (scale_x_binned); coord_trans() and free non-position scales across facets; triple-merge legends (colour+shape+size) and NA keys for size/shape.
vellumplot 0.1.1
- New exported
plot_provenance(): returns the compiled-scene provenance table — one record per emitted mark grob, tying each low-level primitive back to the data rows and trained scales that produced it, with anidthat matches the grob’sdata-vellum-idin SVG and theidcolumn ofvellum::scene_model(). This is the first public consumer of the provenance metadata (previously internal), the substrate for interactivity, linked views, and accessibility. - Row-key provenance is now refined per element for the line, area, ribbon, step, text, and boxplot marks (previously only point/bar/tile/segment/sf/ edges), so a record’s
rowsresolve to the actual data rows an element draws rather than the whole layer. See the scene-contract vignette invellum.
vellumplot 0.1.0
First release. vellumplot is a declarative, pipe-first grammar of graphics that compiles an inspectable spec into a vellum scene, with faceting, coordinate systems, and multi-plot composition. Everything below ships in this first release.
Features
Interactivity declarations (host-agnostic; inert on a static render). Any
mark_*()accepts reserved per-row argsdata_id,tooltip, andhover_group(tidy-eval expressions). They flow into the vellum scene as per-element keys/metadata —data_idbecomes the SVGdata-keyand both surface invellum::scene_model()— the foundation a companion widget uses for hover/select/linking. A plot without them compiles and renders exactly as before. Applies tostat = "identity"marks (points, bars, tiles, segments, edges, hexbins, polar bars) and tomark_sf()— each sf feature (polygon/linestring/point) becomes an addressable, keyed element.Per-element interaction styling: marks also accept
hover_colorandselected_color(constant or column-mapped), carried into the scene so a host (vellumwidget) outlines each element in its own colour on hover/select — overriding the widget-wide theme.Interactive discrete legends: when a plot maps a discrete
color/shapescale and declares interactivity, each legend swatch is tagged with the data series it represents, and every mark carries its series membership. A host (vellumwidget) uses this to make swatches highlight/select their whole series. Inert on a static render and when no interactivity is declared.Auto-display: printing a plot (or composition) draws it into the active graphics device — the RStudio / Positron Plots pane, or a knitr/Quarto chunk — like ggplot2 (via
vellum::display()).summary()shows the inspectable spec tree instead;render_plot()still writes a file.vplot()starts an inspectable, serializablePlotSpec.Output resolution:
vplot(dpi =)sets the authored resolution andrender_plot(dpi =)overrides it per render, so an exported PNG’s pixel dimensions arewidth * dpibyheight * dpi. Compositions inherit the first sub-plot’sdpi(or take an explicitconcat(dpi =)).Marks:
mark_point(),mark_line(),mark_rule()(reference lines viaxintercept/yintercept), andmark_bar()(uses explicityheights, or counts rows per category whenyis omitted).Encodings captured with tidy evaluation:
x,y,color/fill,size,shape,alpha. Scalar values become constant aesthetics.Scales:
scale_x_continuous()/scale_y_continuous()(linear andlog10) with auto-trained domains and ggplot-style expansion; discrete (band) position scales are trained automatically for categoricalx/y(bars);scale_color_continuous()(perceptual ramp),scale_color_discrete()(qualitative palette), and a trained size scale for a mappedsize.Guides: trained x/y axes (breaks, labels, titles), a grey panel with white gridlines, and a legend area that stacks multiple guides — a colour legend (continuous gradient bar or discrete swatches) and a size legend.
Legend layout is measured in millimetres: each guide is sized to its content (a title line above one row per key), the row pitch is driven by the key’s drawn size so large bubble keys never overlap, and the guide block is centred in the legend track. Titles sit directly above their keys, horizontal (top/bottom) legends pack keys tightly instead of spreading them across the full width, and a vertical legend uses the full figure height.
Legend keys match the mark: a colour legend draws the glyph the plot actually uses — a filled circle for point layers, a short line for line-like layers (line/step/smooth/segment/…), and a filled square for bar/area/tile/ polygon layers — instead of always a square swatch.
Legend geometry is themeable:
theme(legend.key.size=, legend.spacing=, legend.margin=)set the key/swatch side, the gap between stacked guides, and the inset around the legend block (all in millimetres).Continuous colour bars now carry white break ticks aligned to their labels, on both vertical and horizontal (top/bottom) legends.
Merged legends: mapping one variable to two aesthetics draws a single legend whose keys carry both encodings — discrete
colour+shapebecome coloured shape keys; continuouscolour+sizebecome colour-graded, size-graduated points. Merging follows ggplot2’s rule (same title and breaks/levels); give one scale a differentname=to keep them separate.Layering: multiple marks on one panel, with scales trained across all layers.
Faceting:
facet_wrap(~var)andfacet_grid(rows ~ cols)split the data into a panel grid with facet strips and aligned, shared axes.Scale resolution (
resolve_scale()/facet_*(scales=)): position scales are shared across panels by default; opt into"free_x"/"free_y"/"free"(independent per panel) for per-panel ranges and axes.Statistical transforms:
mark_histogram()(bin a continuous variable into count bars) andmark_smooth()(an"lm"fit drawn as a line with an optional confidence ribbon).mark_bar()with noyuses the count stat. Map computed variables withafter_stat(), e.g.y = after_stat(density).Position adjustments (
position =): grouped bars stack by default;"dodge"places them side by side and"fill"normalises each group to 1.mark_point(position = "jitter")spreads overlapping points.Datashading:
mark_datashade()aggregates a large point cloud into a density raster (viavellum::datashade()) that fills the panel — cost independent of point count.mark_point(auto = TRUE)switches to this automatically above ~50k rows.More marks: areas/ribbons (
mark_area(),mark_ribbon()), steps (mark_step()), intervals (mark_errorbar(),mark_linerange(),mark_segment()),mark_boxplot(), tiles/heatmaps (mark_tile(),mark_raster()), 2-D binning (mark_bin2d(),mark_hex()),mark_text()/mark_label(), and pie/donut shortcuts (mark_pie(),mark_donut()).More statistical transforms:
mark_density()(kernel density),mark_summary()(aggregateyper category), in addition to the histogram / binning / smooth stats. Map computed variables withafter_stat().Coordinate systems:
coord_cartesian()(view-window zoom),coord_flip(),coord_fixed()/coord_equal()(aspect lock), andcoord_polar()(pie / coxcomb / radar).Themes:
theme_gray()(default),theme_minimal(),theme_bw(),theme_classic(),theme_void(), andtheme()for ad-hoc overrides (panel background, gridlines, text, strip background, legend position, margins).Effects & paints: stroked and point marks take a layer
effects = list(...)argument —glow()(neon halo),outline()(contrasting halo for legibility), andshadow()(drop / ambient).linear_gradient()/radial_gradient()can be used as afillvalue (area / ribbon / bar), andtheme_cyberpunk()ties glow + gradients + a neon palette into the mplcyberpunk look.Hand-drawn (“sketch”) rendering:
sketch()(re-exported from vellum) gives any geometry mark a wobbly, hachure-filled Rough.js look via asketch =argument. Unlike a layer effect it is a geometry property, generated natively in the engine, so it is exact, cross-backend, and works in PDF. It rides three levels, most-specific-wins: a mark’ssketch =, anelement_line()/element_rect()sketch =slot, or the plot-widetheme_sketch()one-liner (hand-drawn gridlines, axes, ticks, marks and legend keys on a paper background).sketch = NAforces an element crisp; text is never sketched (pair a handwriting font).Composition:
hconcat(),vconcat(),concat(), andwrap_plots()arrange several plots on a grid; the aligned path lines up panel edges and can collect guides across sub-plots.inset()overlays a plot, andcompose_annotation()adds figure-level titles and auto-tags (A,B, …).Repeat:
repeat_()replicates a plot across a set of fields, zipping one or more encodings to produce a composition.Blend modes: marks take a
blend =argument (the CSSmix-blend-modeset —"multiply","screen","darken", …). The layer composites as one isolated group against the panel and earlier layers, e.g. two overlapping translucent layers under"multiply".Spatial:
mark_sf()draws the geometry column of ansfobject as a map layer (polygons / lines / points), withcoord_sf()to reproject to a target CRS and lock the map aspect ratio;scale_fill_binned()/scale_color_binned()bin a continuous fill/colour into discrete classes for choropleths.Network:
vgraph()starts a node-link diagram from anigraphgraph — it runs a layout (stress majorization by default, viagraphlayouts;"sparse_stress","backbone","fr","circle", … or a matrix/function), builds a node and an edge table, and locks the aspect with a void theme.mark_edges()draws edges (straight, batched; reciprocal/parallel edges offset off the centre line, self-loops nested, optionalarrow),mark_nodes()the vertices, andmark_node_text()the labels — with a fixed edges-under-nodes draw order. Edges are capped exactly at each endpoint’s node boundary (per vertex, at any resolution), so arrowheads land on the node edge.scale_edge_width()maps a weight to edge width with its own legend.igraph/graphlayoutsare optional (Suggests).Output:
render_plot(plot, path);vellum::render(plot, path)andprint(plot)also work. The compiler is registered on vellum’sas_vellum_scene()seam.
Under the hood
-
Compiled-scene provenance (foundation for interactivity / accessibility / linked views): every emitted mark grob now carries a globally-unique, stable
id(surfacing asdata-vellum-idin SVG), and the compiler builds a serializable row-key / scale-ref table — one record per grob tying it back to the data rows and trained scales that produced it — carried on the compiled scene asattr(scene, "vellumplot_provenance"). Populated on every compile; additive metadata only (raster/PDF output is byte-for-byte unchanged). -
Continuous integration: GitHub Actions run
R CMD check(R release/devel/oldrel on Linux + macOS, with the Rust toolchain thevellumbackend needs), including a nightly run againstvellum’smainto catch cross-layer breaks early, plus a pkgdown build.
Not yet implemented (planned)
Reactivity, 2-D contour stats, and the algebraic * / + layer combinators. Independent non-position (colour/size) scales across facets are not yet supported (those legends stay shared). On the network side, community hulls and alternative idioms (arc/matrix/hive) are deferred.
