library("vegawidget")
library("htmltools")

The purpose of this article is to show how you can use JavaScript to extend Vega’s capabilities. In these examples, we use the vega-view API to interact with a Vega chart using JavaScript. We explore similar ideas in an article on extending using Shiny.

This article has sections on:

It goes without saying that you will need to be comfortable with JavaScript to take advantage of this material. It will be apparent to many of you that my (Ian’s) JavaScript is far from perfect; here are a couple of resources I 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, Mike Bostock’s latest endeavor, is an interactive JS coding environment that emphasizes visualization.

Introduction to vega-view

We can use the vega-view API to modify a 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.

Demo: vega-view

We use the vegawidget() function with an elementId argument, specifying the ID of the HTML element containing the chart. This allows JavaScript code we supply to know which Vega chart to modify.

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

It remains to tell the vega-view API what to do with the chart, for this we need to use JavaScript. Accordingly, the chunk below is written in JavaScript - in the R Markdown file, this is a {js} chunk, rather than an {r} chunk.

This package puts an object into the JavaScript namespace, Vegawidget, which 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. This reflects the elementId = "streaming-demo" in the vegawidget() call above.

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, our view object, on which the vega-view API operates.

Within this then() call, we can operate on the view. The rest of the code is adapted from the original demonstration, where they use vega-view’s change() method to change the data periodically.

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 the example we are building, 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 from events. 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 available 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 call for it by name:

## 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;

Let’s create our event handler based on the "datum" function-body:

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

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



Setting and getting signals

Signals are a formal part of the Vega definition, but are not a part of the Vega-Lite 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.”

Even though signals are not defined in Vega-Lite, we can still use them. Before being rendered, Vega-Lite specifications are compiled into Vega specifications. Once rendered, we can interact with the Vega signals, as long as we know what they are named.

For example, signals are used to drive the behavior of Vega-Lite selections. As of the start of 2019, the Vega-Lite development team are discussing if, and how, signals might be introduced, formally, to Vega-Lite.

For now, we can “hack” our way to a signal by using it in the Vega-Lite specification, then defining the signal by patching the compiled Vega specification. Vega-Lite developer Dominik Moritz demonstrates this technique in an Observable notebook.

Here, we will use the same technique to create an interactive histogram. We will 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.

In the Vega-Lite spec above, in the x encoding, we have defined the bin step as a signal named "bin_width". This is not legal in Vega-Lite. To make this work, we use vega-embed to patch the compiled Vega spec with a definition of the signal. From R, we can include the patch as an option to vega_embed().

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.

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.

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.

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.

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.

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 (and soon getting) 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.

In a future version of this package, you will be able to listen to a dataset, i.e. to specify an action whenever a dataset changes.

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

Next, we create a specification, then a vegawidget with an elementId of "circle"

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.

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}

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

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.

In a future release of this package, we will be able to listen to (and extract) datasets in a Vega chart.