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
.findViewPromise('#streaming-demo').then(
Vegawidget
// when you have the view, run this function using the view
function(view) {
// https://javascript.info/generators
= function*() {
valueGenerator
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
, index) => ({
(valuex: counter,
y: value + Math.round(Math.random() * 10 - index * 3),
category: index
});
)
// extract y values to use next time generator is called
= newData.map(value => value.y);
previousY
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
.change('table', changeSet).run();
view, 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:
## 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
.
vw_handler_event("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;
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.
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")
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
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
.findViewPromise("#histogram").then(function(view) {
Vegawidget.signal("bin_width", bin_width).run();
view;
})
}
// connect the listener-function to the input
.addEventListener("input", on_bin_width);
inp_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
.findViewPromise("#circle").then(function(view) {
Vegawidgetvar changeSet = vega.changeset()
.insert(data_new)
.remove(vega.truthy);
.change("source", changeSet).run();
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.
// {js}
// whenever the input changes, run the updating function
.addEventListener("input", on_angle);
angle
// run the updating function *once* to initialize the input to the data
on_angle()