vplot(series) |>
mark_line(x = t, y = value, color = series, linewidth = 1.2,
tooltip = series, data_id = id) |>
labs(title = "Four overlapping boxes, four distinguishable lines",
x = "week", y = "index") |>
as_widget(height = 420)Picking that measures to the mark
Hover-picking in a web chart usually works on bounding boxes, because a box is all the host knows. That is fine for points. For anything long and thin it gives wrong answers: a diagonal line’s box covers most of the panel, so “the nearest mark” can be a series whose ink is nowhere near the cursor.
vellum knows the actual shape. element_geometry() reports each element’s true vertices in device pixels and as_widget() ships them, so picking happens in two steps. An R-tree shortlists candidates by box, which is safe because a box distance is never greater than the true distance, so nothing in range is missed. The shortlist is then ranked by distance to the real geometry.
Four series whose boxes each cover most of the panel. Run the cursor along one line and the tooltip follows that line, not whichever box you happen to be inside:
What this changes, concretely:
- A diagonal is no longer matched from the far corner of its box. On a chart like this one, two or more series used to tie at box-distance zero almost everywhere in the panel.
- A click inside a filled region hits the region, at distance zero, instead of snapping to whichever border happens to be nearest.
- Graph edges are hoverable. An edge’s box is the whole rectangle its endpoints span, so box-picking matched it from anywhere inside that rectangle, and edges had to be excluded from nearest-mark snapping altogether. They are now measured to the line and compete on their merits.
- Round marks are measured to the disc rather than the square around it.
Vertices are shipped only for the kinds whose shape is not their box: segment, line, polygon, path. A point’s disc and a label’s box are reconstructed from the bounding box the payload already has, so a dense scatter, where payload size actually bites, pays nothing for any of this. Above half a million vertices the block is dropped with a message and picking falls back to boxes. Brush and lasso are unchanged; a box was always the right answer for a rectangular brush.
From a mark back to its rows
The other half of knowing which element you clicked is knowing what is behind it. inspect_source() declares that a host should surface the source data rows for a clicked element: a graph node aggregating twelve observations, a bar summing thirty. It rides on the spec rather than on as_widget(), so it costs a plain widget nothing, and it is inert in a static render.
counts <- aggregate(mpg ~ cyl + gear, data = mtcars, FUN = mean)
counts$cyl <- paste(counts$cyl, "cyl")
counts$gear <- paste(counts$gear, "gears")
counts$id <- paste(counts$cyl, counts$gear)
vplot(counts) |>
mark_bar(x = gear, y = mpg, fill = cyl, position = "dodge",
tooltip = id, data_id = id) |>
inspect_source(on = "click") |>
labs(title = "Click a bar to see the rows behind it",
x = NULL, y = "mean mpg", fill = NULL) |>
as_widget(height = 420)Clicking a bar shows the rows it was computed from, in a small popover. Under the hood the widget answers from the compiled scene’s provenance rather than from a lookup you had to build: each grob knows which input rows it came from. Under Shiny the same gesture sets input$<id>_source, and in plain HTML it fires a bubbling vellum:source DOM event you can listen for. Pass inspect_source(values = TRUE) to ship the row values too, at the cost of a heavier payload.
One detail you only notice when it is missing: when a plot has too many keyed marks to ship as per-element SVG, the widget draws the scene once as a raster and runs interactions off a compact index. That raster is now rendered at 2× and encoded in memory, so it is sharp on a retina screen without touching disk. The bounding boxes and panel rectangles stay in device pixels, so nothing about picking or pan/zoom changes. See A big scatter that stays interactive for that path at 120,000 points.