You can use JavaScript to extend Vega’s capabilities. In these examples, we show how to use the vega-view API to interact with Vega charts. We explore similar ideas in the extending using Shiny article.

This article has sections on:

Here are some resources I (Ian) have used to try to build up my JS capabilities:

  • Eloquent JavaScript, available as a free website or as a physical book; thanks to Stuart Lee for the tip!
  • Observable, an interactive JS coding environment that emphasizes visualization.
  • JavaScript is really well written, focused by topic.
  • MDN Web Docs is really well written, focused by function/object.

Introduction to vega-view

Use the vega-view API to modify a Vega chart once it has been rendered.

Here, we recreate this Vega-Lite streaming demo. Our first step is to create a vegaspec. Note that we have a dataset named "table", but we have not included any data.

spec_table <-
  list(
    `$schema` = vega_schema(),
    width = 400,
    data = list(name = "table"),
    mark = "line",
    encoding = list(
      x = list(
        field = "x", 
        type = "quantitative",
        scale = list(zero =  FALSE)
      ),
      y = list(field = "y", type = "quantitative"),
      color = list(field = "category", type = "nominal")
    )
  ) %>%
  as_vegaspec()

Demo: vega-view

We use the vegawidget() function with elementId to specify the element containing the chart. Here’s what it does, once we have defined the JavaScript:

vegawidget(spec_table, elementId = "streaming-demo")

Now for the rest of the owl. To tell vega-view what to do with the chart; we can add some JavaScript to this page, using a js block. There are other ways to add JavaScript to an RMarkdown page; using a code block makes it easier to discuss the code.

Please keep in mind that purpose of this example is “look at the cool stuff you can do using JavaScript”, rather than “here’s something you can do as you start out using JavaScript”.

// {js}

// find the promise to the view using the CSS selector, then
Vegawidget.findViewPromise('#streaming-demo').then(

  // when you have the view, run this function using the view
  function(view) {
  
    // https://javascript.info/generators
    valueGenerator = function*() {
    
      var counter = -1;
      var minX = -100;
      // there are four elements, we will have four categories
      var previousY = [5, 5, 5, 5];
      
      while(true) {
      
        counter++;
        minX++;
        
        const newData = previousY.map(
          // for each (value, index) in array, return Object
          (value, index) => ({
            x: counter,
            y: value + Math.round(Math.random() * 10 - index * 3),
            category: index
          })
        );
        
        // extract y values to use next time generator is called
        previousY = newData.map(value => value.y);
        
        yield {newData: newData, minX: minX} ;              
      }
    
    }

    // initialize the generator 
    const gen = valueGenerator();
    
    // run this function every 1000 ms
    window.setInterval(() => {

      var newVals = gen.next().value;
      
      // create changeset to insert the object generated, remove old data
      const changeSet = vega
        .changeset()
        .insert(newVals.newData) 
        .remove(t =>  t.x < newVals.minX);
        
      // implement changeset on dataset named `table`, run the view
      view.change('table', changeSet).run();
    }, 1000);
  }
);

This package puts an object called Vegawidget into the top level of the rendered page’s JavaScript namespace; Vegawidget contains a function findViewPromise(). The first part of the JavaScript code is a call to this function, with a single argument, a CSS selector, to specify the HTML element that contains the chart we wish to access.

The findVewPromise() function returns a JavaScript promise to the chart’s view object. The then() call executes once the view promise is fulfilled. It contains a function that takes a single argument, view, the view object on which the vega-view API operates.

Within this then() call, we provide a function to operate on the view. The rest of the code is adapted from the original demonstration. It has three components:

  1. A generator function that yields a new observation (row in a dataset) every time it is called.
  2. A timer that runs code every 1000 ms.
  3. Within the timer, a function that:
    • proposes a data changeset.
    • applies the changeset to the data named table, then runs the view.

The rest of the examples will aim to be somewhat more introductory.

Getting events

Event streams are used extensively within Vega, enabling interactivity. By adding event-listners to the view, we can specify actions to be taken in response to a particular type of event, such as "mouseover", "click", "keypress", etc.

In this section, we will make a “clickable” scatterplot; when we click on a point in the scatterplot, we will print the data-observation (datum) associated with that point. The rest of this section will be devoted to how we tell our view what to do in response to a "click".

Vega’s event-listener documentation specifies that we supply a handler function to be “invoked with two arguments: the event instance and the currently active scenegraph item (which is null if the event target is the view component itself).”

From an R environment, it can be difficult to write and debug JavaScript code. To make this a little easier, this package offers functions to compose JavaScript handler-functions.

Building handler-functions

In this example, we want our handler function to do two things:

  • return the value of the datum (observation) associated with the event.
  • write that value into an element within the HTML document.

These are two separate “things”; the first suggests code that returns a value, the second suggests code that produces a side-effect. Accordingly, this package offers a set JavaScript code-snippets that return values, another set of JavaScript code-snippets that produce side-effects, and a means to compose them.

First, let’s look at snippets that return values. Because all event-handlers take the same two arguments, event and item, we can focus only on the body of the handler-function.

This is the purpose of the vw_handler_event() function. If we call it without arguments, it prints a list of event-handlers that return values:

## arguments: event, item 
## 
##   body_value: item 
## 
##     // returns the item
##     return item;
## 
##   body_value: datum 
## 
##     // if there is no item or no datum, return null
##     if (item === null || item === undefined || item.datum === undefined) {
##       return null;
##     }
##     
##     // returns the datum behind the mark associated with the event
##     return item.datum;

If we want to use a certain handler, we use its only argument: body_value.

## arguments: event, item 
## 
## body_value:
##   // if there is no item or no datum, return null
##   if (item === null || item === undefined || item.datum === undefined) {
##     return null;
##   }
##   
##   // returns the datum behind the mark associated with the event
##   return item.datum;

The handler has a print method that shows the arguments and the function body. If you want to supply a custom handler, you can provide your own function body. For example, this handler-function will be less-robust than the "datum" function from the “library”:

vw_handler_event("return item.datum;")
## arguments: event, item 
## 
## body_value:
##   return item.datum;

We’re halfway there - we have a function that will return a value, but not yet a function that will produce an effect. To add an effect to our function, we can use the function vw_handler_add_effect(). Calling it without arguments will list the available effect-handlers.

## arguments: x 
## 
##   body_effect: shiny_input 
##     params: inputId = NULL, expr = "x" 
## 
##     // sets the Shiny-input named `inputId` to `expr` (default "x")
##     Shiny.setInputValue('${inputId}', ${expr});
## 
##   body_effect: console 
##     params: label = "", expr = "x" 
## 
##     // if `label` is non-trivial, prints it to the JS console
##     '${label}'.length > 0 ? console.log('${label}') : null;
##     // prints `expr` (default "x") to the JS console
##     console.log(${expr});
## 
##   body_effect: element_text 
##     params: selector = NULL, expr = "x" 
## 
##     // to element `selector`, adds text `expr` (default "x")
##     document.querySelector('${selector}').innerText = ${expr}

The effect-handlers are designed to be JavaScript code-snippets that work with a single variable, x, representing the value returned from the value-handler.

We want to print the value to an HTML element, so we will use provided snippet called "element_text". Note that there two parameters that we can supply, selector, to identify the HTML element, and expr, the JavaScript expression to use. The selector parameter is required; the expr parameter defaults to "x", the value.

We supply the parameters as additional arguments to vw_handler_add_effect():

vw_handler_event("datum") %>%
  vw_handler_add_effect("element_text", selector = "#output-datum")
## arguments: event, item 
## 
## body_value:
##   // if there is no item or no datum, return null
##   if (item === null || item === undefined || item.datum === undefined) {
##     return null;
##   }
##   
##   // returns the datum behind the mark associated with the event
##   return item.datum;
## body_effect:
##   // to element `selector`, adds text `expr` (default "x")
##   document.querySelector('#output-datum').innerText = x

The R functions interpolate the parameters into the JavaScript code-snippet. If we were to use this body_effect as is, the text that appears in the HTML element would be [object Object], which is not very informative. Instead, we will use the expr parameter to insert an expression to convert the JavaScript object to JSON text:

handler_event <- 
  vw_handler_event("datum") %>%    
  vw_handler_add_effect(
    "element_text", 
    selector = "#output-datum",
    expr = "JSON.stringify(x, null, '  ');"
  )

handler_event
## arguments: event, item 
## 
## body_value:
##   // if there is no item or no datum, return null
##   if (item === null || item === undefined || item.datum === undefined) {
##     return null;
##   }
##   
##   // returns the datum behind the mark associated with the event
##   return item.datum;
## body_effect:
##   // to element `selector`, adds text `expr` (default "x")
##   document.querySelector('#output-datum').innerText = JSON.stringify(x, null, '  ');

One last note on the effect-handlers: you can add as many (or as few) as you like to a handler-function by piping successive calls to vw_handler_add_event().

If we want to look at what the composed handler-function looks like, you can use the vw_handler_compose() function:

vw_handler_compose(handler_event)
## function (event, item) {
##   (function (x) {
##     // to element `selector`, adds text `expr` (default "x")
##     document.querySelector('#output-datum').innerText = JSON.stringify(x, null, '  ');
##   })(
##     (function () {
##       // if there is no item or no datum, return null
##       if (item === null || item === undefined || item.datum === undefined) {
##         return null;
##       }
##       
##       // returns the datum behind the mark associated with the event
##       return item.datum;
##     })()
##   )
## }

In practice, you are not compelled to use either this function or its friend, vw_handler_body_compose(); the listener functions will know what to do.

Building the interactive elements

We create our vegawidget, then attach our handler, specifying that we want to listen to "click" events, and respond to them using our event-handler:

output_scatterplot <- 
  vegawidget(spec_mtcars) %>%
  vw_add_event_listener("click", handler_body = handler_event)

We create our element that will contain the output of the event-handler:

output_datum <- tags$pre(id = "output-datum")

Demo: events

When you click anywhere in the plotting-rectangle, the observation associated with the mark (if any) where you clicked will be printed below.

output_scatterplot
output_datum


Setting and getting signals

Signals are a formal part of the Vega definition. They are “dynamic variables that parameterize a visualization and can drive interactive behaviors. Signals can be used throughout a Vega specification, for example to define a mark property or data transform parameter.”

There is emerging support for signals in Vega-Lite, where they are called parameters. As of Vega-Lite version 5.20, you still need to make some workarounds.

First, there is a top-level element called params, which is a list. Each element of this list is itself a list, with elements name and value. For example:

params = list(
  list(name = "bin_width", value = 1),
  list(name = "something_else", value = "some string")
)

Then, to implement the parameters, you would substitute the value with an expr list. For example, to set a bin width:

x = list(
  field = "temp",
  type = "quantitative",
  bin = list(step = list(expr = "bin_width")),
  axis = list(format = ".1f")
)

This is the part that is not yet quite working. There are places, like this, where an expr list should work, but doesn’t. There is a workaround, to use signal instead of expr. For example:

x = list(
  field = "temp",
  type = "quantitative",
  bin = list(step = list(signal = "bin_width")),
  axis = list(format = ".1f")
)

This works, but it violates the Vega-Lite schema. There is a pull-request to fix this; when released, we’ll update vegawidget.

Here, we use this workaround to create an interactive histogram. We use the data_seattle_hourly dataset, included with this package, to look at the distribution of temperatures in Seattle in 2010. In addition to the histogram, we will provide a slider-input to specify the bin-width and we will print the bin-width to an element of the HTML document.

Our first step is to create a Vega-Lite specification for a histogram.

spec_histogram <- 
  list(
    `$schema` = vega_schema(),
    width = 300,
    height = 300,
    params = list(
      list(name = "bin_width", value = 1)
    ),
    data = list(values = data_seattle_hourly),
    mark = "bar",
    encoding = list(
      x = list(
        field = "temp",
        type = "quantitative",
        bin = list(step = list(signal = "bin_width")),
        axis = list(format = ".1f")
      ),
      y = list(
        aggregate = "count",
        type = "quantitative"
      )
    )
  ) %>%
  as_vegaspec()

Our output_histogram will be a vegawidget that is contained in an HTML element with an ID of "histogram".

The next step is to use the tags environment from the htmltools package to create a range input that we can use to specify the bin-width. The input will work logarithmically, where the center of the range will correspond to zero, or a baseline bin-width. The range of the input is from -1 to 1, which will correspond to one decade below the baseline bin-width, and one decade above.

input_bin_width <- 
  tags$input(
    type = "range", 
    name = "bin_width", 
    value = 0, 
    min = -1, 
    max = 1,
    step = 0.01,
    style = "width: 400px; margin-bottom: 10px;"
  )

We also define an output element to tell us the bin-width that Vega is using. We seed it with some dummy-text; we will connect it to the Vega chart using a signal-listener.

output_bin_width <-
  tags$p(
    "The histogram bin-width is ",
    tags$span(id = "output-bin-width", "foo"),
    " °F."
  )

Like the event-listener above, we have to supply a signal-handler to the signal-listener.

A signal-handler is a JavaScript function that takes two arguments, the name of the signal and the value of the signal. Like the example above, we define a signal-handler to return the value of the signal, then put the value into the HTML element with ID "output-bin-width", as its text. Note that we are using the expr parameter to specify that we want only three decimal places.

handler_bin_width <-
  vw_handler_signal("value") %>%
  vw_handler_add_effect(
    "element_text",
    selector = "#output-bin-width",
    expr = "x.toFixed(3)"
  )

output_histogram <- 
  vegawidget(spec_histogram, elementId = "histogram") %>%
  vw_add_signal_listener("bin_width", handler_bin_width)

We use the function vw_add_signal_listener() to add the signal-listener to the vegawidget. We specify that whenever the value of the "bin-width" signal changes, Vega will call the signal-handler function, which will update the text in output_bin_width.

We can print the HTML elements to the document; these are “live”, but it remains to define the actions that connect input_bin_width to the Vega histogram.

Demo: signals

You can use the slider to adjust the bin-width for the histogram of temperature observations in Seattle. The slider works on a logarithmic scale; the baseline bin-width is 1.0 °F, its range is from 0.1 °F to 10.0 °F.

The value of the bin-width is printed to as text below the histogram.

input_bin_width
output_histogram
output_bin_width

The histogram bin-width is foo °F.


To connect the input-slider to the histogram, we need to provide a JavaScript function that will run whenever the input-slider changes.

In R Markdown, we can specify JavaScript chunks using js just like we specify R chunks using {r}. This is what we do in the code-chunk below.

In this code, we define a JavaScript function, on_bin_width(), that transforms the value of the input to the value of the bin-width (recall the slider has a logarithmic scale). Then, using Vegawidget.findViewPromise(), we set the signal "bin_width" in the Vega chart and direct the chart to re-run.

We add an event listener to our input-slider, specifying on_bin_width() to run any time the input changes.

Also, we direct this on_bin_width() to run once at initialization.

// {js}
// Reference to the HTML element for the slider-input
var inp_bin_width = document.querySelector("[name=bin_width]");

// define function to call when input changes
function on_bin_width() {

  // translate the input value to a bin_width
  // our baseline bin_width is 1.0 °F
  var bin_width = 1. * Math.pow(10., inp_bin_width.value);

  // update Vega chart
  Vegawidget.findViewPromise("#histogram").then(function(view) {
    view.signal("bin_width", bin_width).run();  
  });    

}

// connect the listener-function to the input 
inp_bin_width.addEventListener("input", on_bin_width);

// run the updating function *once* to initialize the input to the chart
on_bin_width()

Opinion time: admittedly, there are improvements that can be made, both to the visualization and input themselves, and to process by which we connect everything using R. Shiny seems like a fairly attractive option, in comparison.
Hopefully, by taking a few tentative steps with JavaScript, and by exposing them to the development community, we can work towards cleaner implementations.

Setting data

Vega and Vega-Lite both offer the ability to name datasets within a specification. We can use the vega-view API to change a dataset, as was done in the first example in this article.

Let’s consider an example with a dataset that has a single observation, and that observation falls on the unit-circle. We will use an input-slider to change the angle of the observation.

First, we create a range-input that to specify an angle - in our case 0 to 360 degrees. In the HTML document, our input is named "inp_angle".

input_angle <- 
  tags$input(
    type = "range", 
    name = "inp_angle", 
    value = 0, 
    min = 0, 
    max = 360, 
    step = 1,
    style = "width: 400px; margin-bottom: 10px;"
  )

Next, we define our data-output and the data-handler. For the output, we provide an id, "output-data", to identify where in this document to write the data. Our handler function the data set to it, convert it to JSON (with a little formatting), then put the JSON string into our output element named "output-data".

output_data <- tags$pre(id = "output-data")
handler_data <-
  vw_handler_data("value") %>%
  vw_handler_add_effect(
    "element_text",
    selector = "#output-data",
    expr = "JSON.stringify(x, null, '  ');"
  )

Finally, we create a specification, use it to create a vegawidget with an elementId of "circle", then and add our data-listener to it.

spec_circle <- 
  list(
    `$schema` = vega_schema(),
    width = 300,
    height = 300,
    data = list(
      values = list(x = 1, y = 0),
      name = "source"
    ),
    mark = "point",
    encoding = list(
      x = list(
        field = "x", 
        type = "quantitative", 
        scale = list(domain = list(-1, 1))
      ),
      y = list(
        field = "y", 
        type = "quantitative", 
        scale = list(domain = list(-1, 1))
      )     
    )
  ) %>%
  as_vegaspec()

output_chart <- 
  vegawidget(spec_circle, elementId = "circle") %>%
  vw_add_data_listener("source", handler_data)

We can print out our HTML elements to the document, then, like in the signal-example, we will add some JavaScript to define the interactions.

Demo: data

A dataset has a single observation, bound to the unit circle. You can use the input-slider to specify the angle of the observation; you will see the JSON representation of the dataset below the chart.

input_angle
output_chart
output_data

Please note that the next few code-chunks are js chunks rather than {r} chunks.

Like above, we create a JavaScript variable, angle that refers to the input-slider.

// {js}
// Reference to our slider-input
var angle = document.querySelector("[name=inp_angle]");

Next, we create a JavaScript function that, when run, reads the value of the input-slider, then updates the dataset in the Vega chart. This function has no arguments; instead, it refers-to and changes variables in the JavaScript environment.

The first part of the function converts the value of inp_angle into a dataset, data_new, with a single observation, coordinates on the unit-circle.

The second part of the function modifies the chart. Here, we create a changeset inserting data_new, and removing any existing data. Then we change the view’s dataset named "source", then we re-run the view.

// {js}
function on_angle() {

  // translate the input value to x-y coordinates in a new dataset
  var theta = angle.value * Math.PI / 180;
  var data_new = [{x: Math.cos(theta), y: Math.sin(theta)}];

  // changes Vega chart
  Vegawidget.findViewPromise("#circle").then(function(view) {
    var changeSet = vega.changeset()
                        .insert(data_new)
                        .remove(vega.truthy);
                          
    view.change("source", changeSet).run();  
  });  

}

Finally, we specify when our on_angle() function should run. Like above, we add an event listener to the input-slider so that on_angle() runs whenever angle changes.

Lastly, we run the function once at initialization.

// {js}
// whenever the input changes, run the updating function
angle.addEventListener("input", on_angle);

// run the updating function *once* to initialize the input to the data
on_angle()