A polygon with a hole is now hit-tested exactly, not approximately. A
path element ships every ring's vertices concatenated, and the runtime had no
way to tell where one ring ended -- so it read the whole run as ONE closed ring.
That invents a phantom edge from each ring's last vertex to the next ring's
first, and closes the last ring back to the very first vertex. Exact for a
single-ring path (a plain sf polygon, the common case), an approximation for a
polygon with a hole or a multipart feature, and the error is not small: on a
square-with-a-hole fixture the old reading put a point 20 px outside the shape
at 90 px away, and called the shape's own centre a miss.
vellum::element_geometry() gained a ring column in 0.7.0, so the payload
now carries the ring boundaries (nr, rl) and the runtime measures each ring
separately. It follows the engine's own rule -- a path's distance is the minimum
over its rings, each measured as filled, so being inside any ring is a hit
and the fill rule does not enter into it. Client-side answers now match
vl_nearest() exactly on the same scene.
Both columns are omitted when every element is a single ring, so the usual payload does not grow, and a widget saved by an older build keeps working on the one-ring reading.
Bug fix: data marks are no longer drawn offset from their own coordinates.
Under axis_zoom/zoom_marks = "fixed" (both default), the constant-size
counter-scale is applied as an inline CSS transform, which outranks the SVG
transform presentation attribute. Panel marks are positioned by that attribute
(a panel-offset matrix), so the counter-scale replaced their placement and every
glyph rendered at its untranslated geometry — displaced from the scene by the
panel origin. The widget therefore showed the wrong data coordinates, and
disagreed with the PNG and PDF of the same scene.
The inline transform now carries the node's own placement ahead of the
counter-scale, so position and constant size both hold. transform-origin
resolves the scale about the glyph's own centre and translations commute with
that, so the composition is exact for the translate-only matrices vellum emits.
The visible symptom was a crosshair that missed the mark it was tracking:
drawCrosshair() works in scene space (correctly), while hit-testing calibrates
against the marks' real rendered positions, so hover was right and the guide rule
was not. Both agree again.
This is the same clobbering as the 0.8.0 legend-swatch fix, which assumed data points "carry absolute coordinates" and so restricted the counter-scale to marks inside the pannable region. Marks inside that region carry a positioning transform too, so excluding the outside was not enough.
Requires vellum >= 0.6.6, which fixes the coordinate space
element_geometry() reports in — the exact hit-testing below is built on it.
The raster base image is now rendered at 2× for HiDPI screens. A widget is
always viewed at screen resolution, and a 1× image stretched by the browser is
visibly soft on a retina display. scene_png(scale = 2) multiplies dpi while
holding physical size, so the image carries two pixels per device pixel while
the SVG shell stays in device pixels — the space the element bounding boxes and
panel rectangles live in, so nothing about hit-testing or pan/zoom changes.
No new argument: raster mode is chosen precisely when the SVG alternative would be enormous, so the extra bytes are noise against what they replace (at 30,000 keyed points, 446 KB → 877 KB, versus a 14.7 MB per-element SVG). The dense end is cheaper at 2×, not dearer — a saturated canvas compresses better.
The shell's dimensions now come from the scene rather than from the image, via
vellum::vl_convert(). Halving a scaled PNG re-rounds and drifted by a pixel
on a fractional page size, which would have put a small but systematic offset
between the drawn frame and the bounding-box space picking uses.
Exact hit-testing: picking now measures to the mark, not to its box. The
payload carries each keyed element's true vertices in device pixels
(vellum::element_geometry(), computed once at build time); the Flatbush
R-tree still shortlists candidates by bounding box — a box distance is never
greater than the true distance, so nothing in range is missed — and the
shortlist is then ranked by distance to the actual shape. What changes:
Only the kinds whose shape is not their box (segment, line, polygon, path) are shipped; a point's disc and a label's box are reconstructed from the bounding box the payload already carries, so the dense scatter — where payload size actually bites — pays nothing for this. Above 500,000 vertices the block is dropped with a message and picking falls back to boxes.
Brush and lasso are unchanged: a box is the right answer for a rectangular brush, which is what it was always for.
Raster-mode widgets no longer touch disk. The base image is now encoded
with vellum::scene_png(), which returns the PNG bytes, instead of rendering
to a tempfile() and reading it back. Widget output is unchanged.
Animated plots as widgets. as_widget() now accepts a keyframe animation
from vellumplot::animate() and embeds it as a self-contained animated SVG
— resolution-independent and honouring prefers-reduced-motion (both handled
inside the SVG by vellum). It plays on its own through the runtime's existing
inline-SVG mount; since the marks move every frame there is no per-element
interaction index (no hover/brush) and no raster canvas.
Fix: click-to-source did nothing for single-row groups. A grob backed by
one data row had its rows auto-unboxed to a scalar in the payload, so the
runtime's rows.length check skipped it (no event, no popover) — e.g. every
single-county region of a choropleth. The payload now forces arrays, and the
runtime coerces a scalar defensively.
Click-to-source. When a plot opts in with vellumplot::inspect_source(),
clicking a mark surfaces the source data rows behind it: the widget reads the
compiled scene's provenance (keyed by each grob's data-vellum-id), fires a
bubbling vellum:source DOM event (detail = id, rows, fields, and
values when inspect_source(values = TRUE)), sets input$<id>_source under
Shiny, and shows a small values popover. Opt-in rides the spec, so it adds no
as_widget() argument and no payload weight to a plain widget.
Bug fix: hover snapped to the wrong mark on a scaled plot. When the svg was
rendered at a different size than its viewBox implies (the widget's
max-width:100%, the RStudio Viewer pane, an embedded/saveWidget page), hover
highlighted a mark offset from the cursor (usually up-and-left); click was
unaffected because it uses the DOM target. The client→scene hit-test mapping
relied on getScreenCTM() / viewBox math, which can disagree with how the
content is actually laid out when the svg carries width/height attributes.
Hit-testing (hover, brush, lasso) now calibrates the transform from the real
rendered positions of the marks themselves versus their known scene bboxes —
exact under any viewBox / attribute / CSS-scale / zoom combination. Tooltip
anchoring uses the mark's own rendered position.
Breaking: interaction is now declared in the plot, not on as_widget().
The interaction-intent arguments have been removed — hover_color,
selected_color, dim_opacity (now vellumplot::condition()), and the
hover/select/brush/lasso/tooltip/nearest/zoom/axis_zoom/
zoom_marks toggles (now on by default — interactive-by-default). The
signature drops from ~36 to 24 arguments. Per-plot interaction (highlight,
filter, cross-filter) travels with the spec via
vellumplot::select_point()/select_interval()/condition()/filter_by().
Requires vellumplot (>= 0.6.0.9000). (hover_mode, crosshair,
legend_click, select_mode, and the tooltip-appearance args remain until
their spec equivalents land.)
Declarative interactivity: cross-view filtering. A selection defined on one
cell of a composition and referenced by filter_by() on another now
cross-filters — brush/select the source cell and the linked cell narrows to the
matching rows, matched by a cross-view join id. The source cell stays fully
visible; only the target cell hides (scoped by per-cell-unique keys). All in
one widget runtime, no server round-trip.
Declarative interactivity: filters. A vellumplot plot that declares
filter_by(selection) now hides everything outside the selection when that
selection's gesture fires — reusing the cross-filter path. Driven entirely by
the plot spec (no as_widget() argument). Single-view for now; the cross-view
form (brush one panel, filter another) follows.
Declarative interactivity: conditional encodings. as_widget() now reads
the interaction model a vellumplot plot declares (select_point() /
condition()) off the compiled spec and enacts it on the frozen scene. A
hover- or click-driven selection with a condition() on an aesthetic keeps
selection members at their if_true style and applies if_false to
non-members (an explicit colour, or the theme dim as a spotlight) — reverting
when the selection empties. No new as_widget() argument: the behaviour
travels with the plot. (Filters, cross-view, and scale binds follow.)
Bug fix: legend swatches no longer jump off-screen under axis-aware zoom.
With axis_zoom/zoom_marks = "fixed" (both default), the constant-size counter-
scale was applied to every glyph mark, including discrete legend swatches. Because
a swatch is positioned by a transform attribute (unlike data points, which carry
absolute coordinates), the inline counter-scale overrode that attribute and flung
the swatch to a corner. The counter-scale is now restricted to marks inside the
pannable data region, so legend swatches stay put.
Axis-aware zoom (axis_zoom, default TRUE). Wheel/drag zoom scales only
the plot's data region and re-ticks the axes for the visible range — holding the
frame (axes, titles, legend) in place the way a charting library zooms, rather
than scaling the whole scene like an image. Hit-testing (hover/brush/lasso), the
crosshair, and the input$<id>_zoom data range all follow the zoomed data region.
Applies to a single linear cartesian panel (continuous identity/reverse
axes) rendered as SVG; plots with log/date/discrete axes, several panels, or in
raster mode silently fall back to the ordinary whole-scene zoom, so it is safe to
leave on. Set axis_zoom = FALSE for the plain whole-scene zoom. Built on vellum's
pannable-panel contract (vl_viewport(pannable=)) and the panel scale metadata
vellumplot emits (needs the current development vellum/vellumplot).
Constant-size markers on zoom (zoom_marks, default "fixed"). Under
axis-aware zoom, glyph marks (points, circles, hexagons, sector wedges) now keep
their original pixel size and only their positions re-map — so points stay round
and don't stretch into ellipses under the navigator's x-only zoom, the way a
charting library zooms. Positional marks (bars, error bars, lines, areas) still
scale with the data; their stroke width is held constant. Set zoom_marks = "scale" for the old behaviour where glyphs grow with the zoom (useful to read
density). Applies to SVG axis-aware zoom and the raster crisp-point layer.
Interactive continuous colorbar filter (visualMap). When a vellumplot plot
maps a continuous color scale, its colorbar becomes a range filter: drag the
two handles on the gradient bar to a value range and marks whose colour value
falls outside it fade out (and drop out of hover/brush hit-testing); double-click
the bar to reset. The selected range is reported to Shiny as
input$<id>_colorfilter = c(lo, hi) (NULL at the full range). Automatic — no
argument to set — whenever the plot has a continuous colorbar (needs the current
development vellumplot; SVG mode). Discrete/binned colour legends keep their
existing click-to-hide interaction.
Tooltip polish. as_widget() gains tooltip_delay (ms to wait before the
tooltip appears — the highlight is immediate, only the tooltip waits),
tooltip_follow (TRUE, the default, tracks the cursor; FALSE anchors above
the mark), and tooltip_sticky (the tooltip accepts pointer events and lingers
briefly on leave, so tooltips containing links/buttons are usable). The tooltip
also now auto-flips below the cursor when there isn't room above and clamps
horizontally so it doesn't overflow the widget.
Overview navigator (navigator = TRUE). An opt-in strip below the plot that
renders the whole scene in miniature with a draggable, resizable window marking
the visible x-range: drag the window to pan, drag a handle to zoom. Zoom is
x-only — the selected x-range fills the width while the full y-range stays on
screen (a time-series range selector). With axis_zoom (the default) it is
rendered through the axis-aware zoom, so the x-axis re-ticks crisply; otherwise
the view stretches horizontally. It stays two-way in sync with the main view —
wheel/keyboard/brush, the toolbar, and linked-group pan/zoom all move the window,
and moving the window drives them. Useful for scrubbing long series.
navigator_height sets the strip height (default 56px). Client-side; off by
default.
Linked pan/zoom across a group. Widgets sharing a group already linked
selection and hover; now panning or zooming one moves the others to the same
view. The view is shared as a fraction of each widget's own extent (over the
same client-side bus, no Shiny/crosstalk), so linked plots of different sizes —
small multiples — stay aligned. Reset links too.
Brush and view now report data-space coordinates, not just pixels. When the
plot carries a cartesian scale (any vellumplot plot), input$<id>_brush gains
the brushed region's data-space bounds x0d,y0d,x1d,y1d (plus the panel name)
alongside the existing device-pixel rectangle, and input$<id>_zoom gains
data = list(x=, y=, panel=), the visible range in data coordinates. This reads
the per-panel scale descriptors vellumplot now attaches to the scene (requires
the current development vellum (>= 0.4.0.9000) and vellumplot); a raw vellum scene
or a non-cartesian coordinate system reports device-pixel fields only, as before.
Date/time axes report the numeric epoch (days for Date, seconds for POSIXct),
which you map back with as.Date() / .POSIXct(). The fields describe the
visual axes (under coord_flip(), x0d is the plot's y aesthetic); a
discrete axis reports fractional band positions; and an axis built with a custom
scales::transform_*() object (beyond identity / log10 / sqrt / reverse) is
omitted from the data-space fields rather than reported wrong. (Groundwork for
axis-aware zoom.)
Fix: cross-filtered and legend-hidden marks were still hit-testable. A
display-tier cross-filter (crosstalk or vw_filter()) and a legend
legend_click = "hide" toggle set the marks to display:none, but they stayed
in the spatial index — so a nearest-mark hover could still tooltip a hidden
datum, and a brush or lasso could re-select filtered-out points (propagating the
selection back to linked/crosstalk views). Keyboard traversal already skipped
hidden marks; hover, brush, lasso, and raster click-snap now do too, via a single
"inert" guard. Muted (not hidden) legend series stay interactive.
Freehand lasso-select. A third drag mode alongside brush and pan (default
on; disable with as_widget(lasso = FALSE)). The toolbar's mode button now
cycles brush → lasso → pan, and it appears whenever at least two drag modes are
enabled. Drag a loop and every mark whose centre falls inside it is selected —
hit-tested with a point-in-polygon check over the Flatbush spatial index, so it
stays fast on large scenes. It reports through input$<id>_brush like the box
brush, with a lasso = TRUE flag and the loop's bounding box.
Widgets report their current view to Shiny (input$<id>_zoom). After a
zoom/pan settles — wheel, drag-pan release, pinch, keyboard, reset,
zoom-to-selection, or a vw_zoom() proxy call — the widget publishes
list(x=, y=, w=, h=, zoomed=): the current viewBox (device-px) and whether
the view is zoomed in. A deduped state input, so a coordinated dashboard can
react to what the user is looking at. (Data-space limits await axis/scale
metadata in the scene contract.)
Legend click-to-hide and double-click-to-isolate. as_widget() gains a
legend_click argument. The default "select" is unchanged (clicking a
discrete-legend swatch selects its series). "hide" turns the legend into a
visibility toggle — a single click hides or shows the series, a double-click
isolates it (hides every other series; double-click again to restore all) — the
reflexive legend interaction from plotly / ECharts / Highcharts. "mute" is the
same but dims the series rather than removing it (keeping its layout). Hovering a
swatch still highlights its series under every policy, and hidden series drop out
of hover/tooltip hit-testing. Works with keyboard (Enter/Space on a focused
swatch toggles) and is independent of the crosstalk cross-filter (the two never
clobber each other). Entirely client-side.
Shared (unified) hover tooltips and a crosshair. as_widget() gains a
hover_mode argument. The default "closest" is unchanged (hover shows the
single nearest mark), but "x" (or "y") turns on a unified hover: every
mark sharing the hovered x (or y) position is highlighted at once and listed
together in one tooltip box — the shared readout multi-series line and
time-series charts expect, where you want every series' value at the cursor's
x rather than one point. A companion crosshair argument (default FALSE)
draws a guide rule at the hovered position: a vertical rule at the shared x in
"x" mode, a horizontal one in "y" mode, and a full cross through the mark
in "closest" mode. Both are entirely client-side and need no extra scene
metadata — the mark positions come straight from the element index — so they
work on any keyed vellumplot plot or raw vellum scene, and in raster mode.
Unified hover snaps along its axis so the readout tracks the cursor anywhere in
the plot; the hover Shiny input still reports the single nearest (primary)
mark, so the read-back contract is unchanged.
Fix: hover/selection feedback rings were drawn offset from the mark. When
the widget container was larger than the plot's rendered box — which happens
routinely, as htmlwidgets stamps an explicit height on the container and a
fluid layout can stretch its width — the feedback overlay filled the whole
container while the base SVG only filled its aspect-locked box. The overlay's
viewBox then letterboxed (centred), so every hover/selection ring drew off
the real mark: downward when the container was taller (e.g. 68px low for a
520px-tall container over a 384px plot) and sideways when it was wider. It
looked like the wrong mark was picked. Hit-testing itself was always correct;
only the visual feedback was displaced. The svg and its overlays now share a
shrink-to-fit stage, so the overlay tracks the svg box exactly at any container
size.
text argument on as_widget(). Choose how text is written into the SVG,
passed through to vellum::scene_svg(): "native" (the default) emits
selectable <text> referencing system fonts — smaller when the page has the
font, post-processable, and better for accessibility and LLMs — while
"outline" emits pixel-faithful, font-independent glyph paths. Applies to the
per-element SVG path only; in raster mode text is baked into the base image and
the argument is ignored (with a warning if set explicitly) (#1).
New articles. Two articles document the features added since 0.4.0:
Linking views with crosstalk (coordinating a widget with DT / plotly /
leaflet and crosstalk's filter_* inputs) and Very large scenes (raster
mode, the spatial index, the columnar payload, and crisp zoom).
Crisp zoom in raster mode. When you zoom into a raster-mode plot, the base
image used to upscale and blur. The widget now redraws the points sharply on a
<canvas> overlay while zoomed in — sampling each point's colour straight from
the rendered image and its position/size from the element index, so the crisp
layer matches what vellum drew. It engages only when zoomed in (the faithful,
anti-aliased base image still shows at the full view), redraws just the points in
view, and degrades gracefully to the image alone where a 2D canvas context isn't
available. Entirely client-side; no change to the payload or to small/moderate
(SVG-mode) plots.
Very large scatterplots are navigable (raster mode). as_widget() gains a
mode argument ("auto" / "svg" / "raster"). In "auto" (the default), a
scene with more than raster_threshold keyed elements (default 20000) is drawn
once as a single embedded image instead of one SVG node per element, and all
interaction — hover tooltips + highlight, click/brush select, pan/zoom — is driven
client-side from the element index (bounding boxes + keys) rather than the DOM.
A 150k-point keyed scatter that previously produced a ~75 MB SVG with 150,000 DOM
nodes now ships as a ~0.8 MB image plus a compact index, with a handful of DOM
nodes, and stays smooth to hover and pan. Small and moderate plots are unchanged
(they keep the per-element SVG). Trade-offs in raster mode: per-element grammar
colours, per-mark screen-reader focus, and display-tier cross-filtering don't
apply (there are no per-element nodes), and a zoomed-in view is a scaled raster
until re-rendered. Adds a dependency on base64enc.
Smoother hover, brush, and pan on large plots. Two client-side changes lift the per-interaction cost that made big scatterplots laggy:
Much faster, smaller payload for large plots (columnar element table). The
keyed-element metadata as_widget() embeds is now serialised in a columnar
form (one array per field) instead of one JSON object per element. This is a
transparent wire-format change — the widget behaves identically — but it removes
the per-element serialisation cost that dominated at large N. On a 150,000-point
keyed scatter the payload build + serialise dropped from ~24 s / 89 MB to
~0.4 s / 12 MB (~60x faster), so a big interactive scatter is no longer choked
by payload generation. Small and moderate plots are unaffected. (This is Phase 1
of vellum's big-data interactivity plan; the browser-side DOM/hover work for
truly huge scatters follows in later phases.)
Error bars and boxplots are interactive. Now that vellumplot keys these
statistical marks, the widget hovers, tooltips, clicks/selects, and brushes them
as units: an error bar's bar + caps, or a box's rect + median + whiskers, all
light up and select together because they share one data-key (outliers stay
individually addressable). Brush selection no longer double-counts a mark whose
key spans several SVG elements — input$<id>_brush$keys reports each such key
once. No runtime change was needed for the core behaviour; it already grouped
every node sharing a key.
Server-to-client proxy (vellumwidget_proxy()). A Shiny app can now drive an
already-rendered widget from the server without re-rendering it — no
renderVellumwidget() round-trip, no lost pan/zoom. Get a handle with
vellumwidget_proxy(outputId) (inside an observe()), then pipe it through the
verbs:
vw_select() / vw_clear_selection() — set or clear the selection (projects
across hover_group and propagates to linked / crosstalk widgets, exactly
like a user click, and updates input$<id>_selected);vw_filter() / vw_clear_filter() — cross-filter the widget (show only the
given keys, dim the rest; display tier, data untouched);vw_zoom() / vw_reset_zoom() — frame a set of keys, or restore the full view.All keys are the element data_ids — the same identifiers you read back through
input$<id>_selected. This completes the two-way Shiny story begun with the
input read-back in 0.3.0; see the expanded Using vellumwidget in Shiny article.
Adopted vellum's renamed vl_* graphics primitives (grid collision fix).
Shiny input read-back. A widget rendered with vellumwidgetOutput() now reports the
user's interactions to the server as reactive inputs keyed by the output id:
input$<id>_selected (selected keys, state), input$<id>_click (list(key=),
event), input$<id>_hover (hovered key or NULL, state), and
input$<id>_brush (list(keys=, x0=, y0=, x1=, y1=), event). Values are element
data keys, so they map straight back to your data. Emitted only in a live Shiny
session — a static render is unchanged and produces no input traffic. See the new
Using vellumwidget in Shiny article. (Driving the widget from the server — a
vellumwidget_proxy() — is a planned follow-up.)
Accessibility (a11y = TRUE, on by default). The interactive widget is now
keyboard- and screen-reader-navigable, not a mute image:
role="graphics-document" +
aria-roledescription), labelled from the scene's title/description (which
vellumplot sets automatically) or an explicit as_widget(alt =);graphics-symbol with a roving tabindex — arrow
keys move between marks, Enter/Space toggles selection, Escape exits;aria-live region announces the focused / selected mark;a11y; a11y = FALSE restores the previous output exactly. See the
vellumplot Accessibility article.Interaction-depth release (ROADMAP §4).
tooltip = (e.g. via glue()) may use <b>/<i>/<br> for bold/italic/
line breaks. Data values are escaped and only inert, attribute-free tags are
honoured — no scripts, handlers, or attributes (no XSS). New
as_widget(tooltip_style =) themes the tooltip box (background/color/fontsize/
max_width).+/- zoom, and 0 resets.as_widget(export_filename =, export_scale =) set the
download filename base and a hi-res PNG multiplier; exports capture the current
(zoomed/panned) view. A "copy PNG to clipboard" toolbar button appears where
the Clipboard API is available.querySelectorAll per key, and the nearest-mark
hover scan is throttled to one per animation frame.Deferred: a spatial index for hit-testing (the DOM cost was the real bottleneck, now cached; bbox scans are cheap and datashade collapses huge clouds to a raster); coordinated zoom across linked widgets and URL deep-linking; PDF export.
First release. vellumwidget turns a vellum scene or a vellumplot plot into a
self-contained, client-side interactive HTML widget via a single terminal
as_widget() pipe — no Shiny, no server round-trip. Everything below ships in
this first release.
as_widget() compiles a vellumplot plot or a raw vellum scene and bundles its
SVG + scene_model() element table into an htmlwidget.tooltip, falling back to the key) and
highlight with inverse-dim; a hovered element's hover_group highlights the
whole group. Hover snaps to the nearest mark when the cursor isn't directly
over one.viewBox, plus
zoom-to-selection and reset.as_widget() arguments
(tooltip/hover/select/brush/zoom/toolbar/nearest).color/shape scale on an interactive
plot, each legend swatch drives its whole data series: hovering a swatch
highlights the series (the swatch stays lit), clicking it selects the series
(and links across views / crosstalk). Automatic — no extra arguments.as_widget(group=) links vellumwidget widgets client-side (no
dependency): selecting/brushing in one highlights the same data keys in the
others, projecting by hover_group (select one, select the series).
as_widget(crosstalk = SharedData) bridges to the crosstalk ecosystem
(plotly / leaflet / DT and filter_* inputs) via a SelectionHandle +
FilterHandle; a crosstalk filter hides the non-matching marks (display-tier
cross-filter). crosstalk is a Suggests, loaded only when used.as_widget(hover_color=, selected_color=, dim_opacity=)
sets the look for the whole plot (any R or CSS colour).vellumplot marks' hover_color/selected_color
(constant or column-mapped) style each element individually and override the
theme. Both use CSS variables with a defaults ← theme ← per-element cascade.The JS runtime is TypeScript in srcts/, bundled by esbuild into the committed
inst/htmlwidgets/vellumwidget.js (so the R package installs with no Node).