Skip to content

Releases: r-vellum/vellumwidget

vellumwidget 0.8.0

Choose a tag to compare

@schochastics schochastics released this 01 Aug 20:13
f72a09d

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:

    • Graph edges are hoverable. An edge's box is the whole rectangle its
      endpoints span, so a box-distance "nearest" matched it from anywhere inside
      that rectangle; edges had to be excluded from the open-space snap entirely.
      They are now measured to the line and compete on their merits.
    • A diagonal is no longer matched from the far corner of its box. On a
      four-series line chart whose boxes each cover ~91% of the panel, a cursor on
      a line picks that line — where previously two or more series were tied at
      box-distance zero at 39 of 40 probe positions.
    • A click inside a filled region hits the region, at distance zero, rather
      than snapping to whichever border happens to be nearest.
    • Round marks are measured to the disc, not to the square around it.

    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.

vellumwidget 0.7.0

Choose a tag to compare

@schochastics schochastics released this 27 Jul 07:48
  • 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.)

vellumwidget 0.6.0

Choose a tag to compare

@schochastics schochastics released this 20 Jul 08:43
  • 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 pi...

Read more

vellumwidget 0.5.0

Choose a tag to compare

@schochastics schochastics released this 16 Jul 20:45
  • 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:

    • Spatial index. Nearest-mark hover and rectangular brush now hit-test
      against a Flatbush R-tree
      (O(log n) / O(k)) instead of scanning every element each time. The nearest-mark
      scan runs on every pointer move, so this is the change you feel most.
    • Cheaper hover dim. Above a threshold, hovering dims the plot once (via the
      holder's opacity) and re-draws the hovered marks crisply in a small overlay —
      O(hovered) — instead of restyling every element via CSS (O(n)), which forced
      a full-scene style recalc on each hover. Small and moderate plots keep the exact
      previous per-mark dim. (Phase 2 of vellum's big-data interactivity plan.)
  • 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.

vellumwidget 0.4.0

Choose a tag to compare

@schochastics schochastics released this 15 Jul 06:56
  • 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.

vellumwidget 0.3.0

Choose a tag to compare

@schochastics schochastics released this 10 Jul 07:13
  • 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:

    • the SVG is announced as an interactive chart (role="graphics-document" +
      aria-roledescription), labelled from the scene's title/description (which
      vellumplot sets automatically) or an explicit as_widget(alt =);
    • every mark is a focusable graphics-symbol with a roving tabindex — arrow
      keys move between marks, Enter/Space toggles selection, Escape exits;
    • a polite aria-live region announces the focused / selected mark;
    • a visually-hidden data table lists every mark for assistive tech.
      All gated on a11y; a11y = FALSE restores the previous output exactly. See the
      vellumplot Accessibility article.

vellumwidget 0.2.0

Choose a tag to compare

@schochastics schochastics released this 08 Jul 12:36

vellumwidget 0.2.0

Interaction-depth release (ROADMAP §4).

  • Rich tooltips. Tooltip text now renders as safe HTML: an author-built
    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).
  • Touch + keyboard. The widget is driven by pointer events, so pan/brush/
    hover work with mouse, touch, and pen from one path; a two-finger pinch
    zooms. With the widget focused, arrow keys pan, +/- zoom, and 0 resets.
  • Configurable export. 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.
  • Large-N performance. Hover/selection highlighting uses a per-render
    key→node cache instead of a querySelectorAll per key, and the nearest-mark
    hover scan is throttled to one per animation frame.
  • New "Interactive widgets: a tour" article.

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.

vellumwidget 0.1.0

Choose a tag to compare

@schochastics schochastics released this 08 Jul 07:53

vellumwidget 0.1.0

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.

Features

  • as_widget() compiles a vellumplot plot or a raw vellum scene and bundles its
    SVG + scene_model() element table into an htmlwidget.
  • Hover — tooltip (from the declared 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.
  • Click — select (single / multiple modes); every element sharing a key
    toggles together.
  • Brush — drag a rectangle to select every element it covers.
  • Pan / zoom — mouse wheel and pan-mode drag reframe the SVG viewBox, plus
    zoom-to-selection and reset.
  • Toolbar (on hover) — brush/pan mode toggle, zoom-to-selection, reset zoom,
    download SVG, download PNG, fullscreen.
  • Everything is opt-outable via as_widget() arguments
    (tooltip/hover/select/brush/zoom/toolbar/nearest).
  • Legend interaction. For a discrete 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.
  • Linked views. 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.
  • Customisable interaction styling, at two composing levels:
    • Widget themeas_widget(hover_color=, selected_color=, dim_opacity=)
      sets the look for the whole plot (any R or CSS colour).
    • Per-element grammarvellumplot 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).