Skip to contents

vellumwidget 0.8.0.9000

  • 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.

vellumwidget 0.8.0

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

  • 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

  • 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.

vellumwidget 0.5.0

  • 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

  • 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: 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

  • 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

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

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).