
Retained-mode superpowers: hit-testing and editable nodes
Source:vignettes/retained-mode.Rmd
retained-mode.Rmdgrid, and most drawing APIs, are immediate mode: a call paints pixels and forgets everything about them. vellum is retained mode. The scene you build is kept as a tree until you render it, and that tree can be queried and edited afterwards. It buys you three things: naming and editing nodes, hit-testing, and reading back a per-element model of the scene.
Naming nodes
Every grob and viewport takes an optional name. A name
turns a node into something you can look up and modify later.
dots <- vl_scene(5, 3, bg = "white") |>
draw(circle_grob(x = 0.25, y = 0.5, r = 0.16, name = "a",
gp = vl_gpar(fill = "#bdc3c7", col = NA))) |>
draw(circle_grob(x = 0.55, y = 0.5, r = 0.16, name = "b",
gp = vl_gpar(fill = "#bdc3c7", col = NA))) |>
draw(circle_grob(x = 0.85, y = 0.5, r = 0.16, name = "c",
gp = vl_gpar(fill = "#bdc3c7", col = NA)))
node_names(dots)
#> [1] "a" "b" "c"node_names() lists the names in paint order, and
get_node() returns the node itself, so you can inspect a
value you built earlier.
get_node(dots, "b")
#> <vellum::grob_circle>
#> @ name : chr "b"
#> @ gp : <vellum::vl_gpar>
#> .. @ col : logi NA
#> .. @ fill : chr "#bdc3c7"
#> .. @ lwd : NULL
#> .. @ alpha : NULL
#> .. @ lty : NULL
#> .. @ lineend : NULL
#> .. @ linejoin : NULL
#> .. @ linemitre : NULL
#> .. @ fontfamily: NULL
#> .. @ fontface : NULL
#> .. @ fontsize : NULL
#> .. @ lineheight: NULL
#> @ vp : NULL
#> @ id : NULL
#> @ role : NULL
#> @ keys : NULL
#> @ meta : NULL
#> @ x : unit [1:1] 0.55npc
#> @ y : unit [1:1] 0.5npc
#> @ r : unit [1:1] 0.16npc
#> @ sketch: NULLEditing a node
edit_node() returns a new scene with one node’s
properties changed. It is copy-on-modify: the original scene value is
untouched, so you can derive variants without disturbing the source.
Here we highlight the middle dot.

The original is unchanged:
dots
This is the mechanism a host uses for hover and selection: keep one
scene, and on an interaction re-derive it with the touched node
restyled, then re-render. Flagging that node’s viewport with
cache = TRUE (a repaint boundary) makes the re-render
cheap, since only the changed subtree is re-rasterised.
Hit-testing
hit_test() answers the inverse question: given a point,
which node is drawn on top there? grid offers only
grid.locator(), but a retained scene can be compiled into a
colour pick-buffer, so the answer is exact with respect to geometry,
clipping, and paint order. Coordinates default to "npc"
(0..1, y up); pass units = "px" for device
pixels.
hit_test(dots, x = 0.25, y = 0.5) # over dot "a"
#> [1] "a"
hit_test(dots, x = 0.55, y = 0.5) # over dot "b"
#> [1] "b"
hit_test(dots, x = 0.05, y = 0.1) # empty space
#> NULLA point over a named grob returns its name; over an unnamed grob it
returns NA; over blank canvas it returns NULL.
That is enough to route a click back to the datum that drew the
mark.
A per-element model of the scene
hit_test() picks one node; scene_model()
returns the whole picture. It walks a rendered scene and returns one row
per drawn element of the keyable marks (points, circles, rects,
hexagons, sectors, segments), pairing each element’s identity with its
resolved device-pixel bounding box.
sm <- scene_model(dots)
str(sm, max.level = 1)
#> List of 2
#> $ elements:'data.frame': 3 obs. of 14 variables:
#> $ panels :'data.frame': 0 obs. of 5 variables:
sm$elements[, c("mark", "name", "x", "y", "w", "h")]
#> mark name x y w h
#> 1 circle a 120 144 92.16 92.16
#> 2 circle b 264 144 92.16 92.16
#> 3 circle c 408 144 92.16 92.16The real power shows up when marks carry a data key (and
optional free-form meta), which the batched grobs accept
per element. The key is emitted by the SVG backend as
data-key on each element and surfaced here, so a host can
render the SVG once (scene_svg()), then use this table to
map a DOM event back to the originating row of data.
keyed <- vl_scene(5, 3, bg = "white") |>
draw(points_grob(
x = vl_unit(c(0.25, 0.55, 0.85), "npc"), y = 0.5,
size = vl_unit(6, "mm"),
key = c("row-1", "row-2", "row-3"),
gp = vl_gpar(fill = "#3a7bd5", col = NA)
))
scene_model(keyed)$elements[, c("mark", "key", "x", "y")]
#> mark key x y
#> 1 point row-1 120 144
#> 2 point row-2 264 144
#> 3 point row-3 408 144Why this matters
The retained scene graph is what separates vellum from an immediate-mode drawing layer. Because the tree survives past construction:
- nodes can be named, inspected, and edited
(
node_names(),get_node(),edit_node()) without rebuilding the scene; - any point can be hit-tested back to the node that
drew it (
hit_test()); - the whole scene can be read back as a table of elements with
keys and geometry (
scene_model()), the host-agnostic bridge to interactivity.
These are the primitives an interactive grammar layer builds tooltips, brushing, and linked selection on top of. ```