Skip to contents

Most of vellumplot describes a static picture. But interaction can be part of the plot too — declared in the spec, travelling with it, and enacted by any capable host (today, vellumwidget). This is different from configuring a widget after the fact: the interaction is a first-class, serialisable piece of the plot.

Everything on this page is inert on a static render — a plot with interactions compiles and draws exactly like one without (the figures below are the static renders). The interaction comes alive only when a host such as vellumwidget::as_widget() enacts it.

Selections

A selection is a named set of data elements defined by a user gesture. On its own it does nothing; you refer to it elsewhere. Declare one with select_point() (a click or hover, or a legend value) or select_interval() (a brush or lasso, optionally locked to an axis):

p |> select_point("hi", on = "hover") # hover a mark
p |> select_interval("brush", on = "x") # drag an x-range

on sets the gesture; empty = TRUE (the default) means an empty selection contains everything — so an untouched plot shows its full self and interacting narrows it.

Conditional encoding: condition()

Use condition() as the value of an aesthetic to make it depend on selection membership: members get the first value, non-members the second.

df <- data.frame(x = mtcars$wt, y = mtcars$mpg, cyl = factor(mtcars$cyl))
vplot(df) |>
  mark_point(x = x, y = y, color = condition("hi", cyl, "grey80")) |>
  select_point("hi", on = "hover")
A scatter plot. It plots y (vertical axis) against x (horizontal axis), where colour shows cyl. Based on 32 observations.152025302345yxcyl468

condition() is transparent to the grammar: the if_true branch trains the colour scale and draws the legend exactly as color = cyl would, so the static render is the full, meaningful plot. With empty = TRUE, nothing is selected initially, so every point shows its if_true colour; once a host activates the selection, non-members switch to if_false (here "grey80") — the spotlight.

Omit if_false to fall back to the theme’s dim appearance:

mark_point(x = x, y = y, color = condition("hi", cyl)) # non-members just dim

Filtering: filter_by()

filter_by() shows only the selection’s members, hiding the rest:

vplot(df) |>
  mark_point(x = x, y = y) |>
  select_interval("brush", on = "xy") |>
  filter_by("brush")

Cross-filtering across views

The real power is pointing a second view at a selection defined on a first — brush one panel, a linked panel narrows to those rows while the source stays full. Define the selection once, add_selection() it on the source, and filter_by() it on the target:

sel <- select_interval("brush", on = "xy")
hconcat(
  vplot(df) |> mark_point(x = x, y = y) |> add_selection(sel),
  vplot(df) |> mark_point(x = y, y = x) |> filter_by(sel)
)
A composition of 2 plots arranged in a 1 by 2 grid.152025302345yx234515202530xy

The two views share row identity, so a gesture in one maps to the matching rows in the other. This cross-view coordination is exactly what a widget flag cannot express — it lives in the plot.

Overview + detail: bind_scale()

bind_scale() binds a panel’s view to an interval selection on another (an overview), so brushing the overview pans/zooms the detail. It is declared in the spec today; host enactment is in progress.

Which marks are addressable

Give a mark an identity with data_id= (and, optionally, a tooltip=) and it becomes hoverable, selectable and cross-filterable. This is no longer limited to the batched marks (points, bars, tiles, segments): a line or step series is one addressable object, a filled mark_area() / mark_ribbon() band is one polygon, mark_text() labels are addressable per datum, mark_label() keys by its rounded background box, and an sf polygon / choropleth keys per feature.

# hover the whole trend line as one object
vplot(pressure) |>
  mark_line(x = temperature, y = pressure, data_id = "vapor-pressure") |>
  select_point("series", on = "hover")

A mark you leave without a key is simply not addressable — and, for lines, polygons and text, it contributes no row to the interaction model at all. That is deliberate: a plot’s gridlines and axis labels are unkeyed text and lines, and they must not drown the marks that carry meaning. So absence of a key is a choice you are making, not a limitation.

What a host does with it

vellumplot compiles once and carries the interaction declarations plus the per-element metadata a host needs (interaction_model() returns the declaration block). The host reads them and wires the gestures it already performs on the frozen scene — highlight, hide, pan/zoom — with no recompilation. Turn any of the plots above into a live widget with:

library(vellumwidget)
vplot(df) |>
  mark_point(x = x, y = y, color = condition("hi", cyl, "grey80")) |>
  select_point("hi", on = "hover") |>
  as_widget()

The interaction is display-tier by design: it reacts on the frozen scene (highlight, filter-by-hiding, pan/zoom) and never recomputes the grammar. See select_point(), select_interval(), condition(), filter_by(), add_selection(), and bind_scale() in the reference.