library("vegawidget") library("htmltools")
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:
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()
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:
table
, then runs the view.The rest of the examples will aim to be somewhat more introductory.
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.
In this example, we want our handler function to do two things:
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:
vw_handler_event()
If we want to use a certain handler, we use its only argument: body_value
.
vw_handler_event("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;")
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.
vw_handler_add_effect()
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")
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
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)
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.
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")
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
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.
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
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.
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.
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()
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.