D3 stands for Data Driven Documents
. D3 can be used to create any chart imaginable using web standards only. For some examples, see Mike Bostock's bl.ock collection and the official docs.
The power of d3
comes from its ability to bind data to the DOM and for being an incredibly powerful toolbelt library that serves as a visualization kernel. On the other hand, Vue is a popular progressive framework for building user interfaces that is both easy to use and incrementally adoptable.
In addition, vue offers a powerful template and rendering framework that let's us compose applications as a set of reusable components
, combined with a powerful reactive
event system.
These days every major application framework offers bindings to Javascript. Hence, it makes sense to combine the power of vue
and d3
to create reactive data driven visualization components based on modern javascript i.e. ES6.
As these components are completely Javascript based, they can be used inside .NET
, Python
and R/Shiny
applications, without modifying any code. In that sense, our goal here is not to create an R HTMLWidget
, but to create Javascript code that can be copied (almost) verbatim, while still working in other environments that support Javascript.
This text discusses the details of a technical prototype that involves in a number of custom d3
driven bubble chart components
, together with a main vue app
and a reactive event system
that let's components communicate with each other, with the main vue app and even with R/Shiny. The goals is to stay close to Javascript while still being able to use most of the code in a shiny app.
For more information on R based web applications and widgets, see shiny and HTMLWidgets, respectively. For a collection of my personal bl.ocks, see here. For convenience, we also use a small set of functions from Lodash, another very useful toolbelt library.
Our shiny toy app looks like this:
Our toy app has three parts: a top section showing hover event info, a mid section with 3 vue components and a lower section that shows events that shiny received from the vue components. The top two sections represent our main vue instance that holds 3 child components i.e. our bubble charts.
Each bubble plot is a d3
based vue component
that shows a number of circles as it's data, using a d3
scale to position them on an x-axis. The data for each chart is a simple array, containing numbers drawn from a normal distribution. On a key press we can generate new data for each chart, drawing a dynamic number of data points from a set of normal distributions.
The data itself has no meaning, other than allowing us to draw some circles with them. At each generation, the circle radii are animated to a random radius, just for fun. As the number of data points on each generation is dynamic, we can apply the d3
enter, update, exit pattern to our charts. In our case, the enter selection is green, the update selection is blue and the exit selection is gray.
This toy application demonstrates a number of concepts:
- show how to encapsulate complex
d3
logic in a vue component - automatically resize components based on the parent container dimensions
- make components react to data changes (props) send down from the parent (the main app) to the child components (the bubble components)
- let components emit events to their parent based on
hover
andclick
events - as an alternative, use a global
event bus
to emit events, regardless if there is a child parent relationship - use the same components in both a vanilla Javascript context and an R / shiny context, without changing a single line of code inside the components
- show how to update components from R
- show how to send events from Javascript to R
The main vue instance, has an id of app
and holds 3 bubble chart instances. On the HTML side of things, the main vue app looks like this:
<div id="app" ref="app">
...
<bubble-chart ...></bubble-chart>
<bubble-chart ...></bubble-chart>
<bubble-chart ...></bubble-chart>
</div>
The 3 bubble chart instances are created via custom <bubble-chart></bubble-chart>
tags, which represent vue components.
During the lifetime of the vue instances, the markup will be replaced with actual DOM nodes that make up the charts. Some of these nodes are created and controlled by vue
, while others are created and controlled by d3
(more on this below).
On the Javascript side of things, we first define a main vue app:
var app = new Vue({
el: "#app",
data: {
width: 960,
height: 500,
timeline_data1: [-300, 300],
timeline_data2: [-300, 300],
timeline_data3: [-300, 300],
hover_payload: null
},
methods: {
...
},
..
})
We'll explain the details and other parts of the main vue instance below.
In addition to our main vue app, we also define a bubble chart component as follows:
Vue.component("bubble-chart", {
props: ["id", "width", "height", "data"],
...
methods: {
...
},
...
template: `
<div class = "custom">
...
<svg :id="id" width="100%" :height="height" ref="svg"></svg>
</div>`
})
Among other things discussed below, the components involves a template
declaration, that asks vue to render a div
with an svg
nested inside it. During the lifetime of the vue
component, the template is rendered to the DOM. At a later stage in the lifetime of our instance, we'll use d3
to inject additional content into our svg
. The outer div
sets a CSS class that creates some additional room and styles for our chart instance:
#app {
width: 100%;
padding: 10px;
background-color: rgba(180, 177, 177, 0.973);
box-sizing: border-box;
}
.custom {
margin: 10px;
padding: 10px;
background-color: rgba(214, 213, 213, 0.658);
box-sizing: border-box;
}
The main app holds data on its container width
and height
, as well as some initial data for our bubble charts i.e. bubble_data1
, bubble_data2
and bubble_data3
.
data: {
width: 960,
height: 500,
bubble_data1: [100, 200],
bubble_data2: [100, 200],
bubble_data3: [100, 200],
hover_payload: null
}
At a later stage, we use the hover_payload
field to store hover event data on the main app that are emitted by the bubble charts when we're interacting with the various circle elements on mouseover
events.
Some of the data from the main vue instance is passed down to the child components i.e. the bubble charts. In vue, props
are the default way to pass data from a parent to a child component. You can think of a prop
as custom attribute that can react to data changes.
In our case, the bubble charts take the following props:
Vue.component("bubble-chart", {
props: ["id", "width", "height", "data"],
...
})
On the HTML part, we can tell the main vue app to send down data as props
as follows:
<bubble-chart ... id="chart1" :width="width" :height=150 :data="bubble_data1"></bubble-chart>
In our case, we pass two static props for both the id
and chart height
, while the chart width
and data
are reactive.
As noted in the previous section, our custom bubble chart component receives a width
prop from its parent:
<bubble-chart ... :width="width" ...></bubble-chart>
The value of width
in our case is determined in the main vue instance and send down to let each bubble chart know how much horizontal space it can consume.
How do we know what value for width
to pass down as a prop?
Consider our main app. As an example, we can use the mounted
life cycle hook to attach an event handler to the main window
object to listen for resize
events.
var app = new Vue({
el: "#app",
...
mounted: function () {
this.setContainerDims();
window.addEventListener("resize", this.setContainerDims);
...
},
methods: {
setContainerDims() {
this.width = this.$el.clientWidth;
this.height = this.$el.clientHeight;
},
...
},
beforeDestroy() {
window.removeEventListener("resize", this.setContainerDims);
}
})
In the code above, in the mounted
life cycle hook, we attach a listener that listens for window resize
events. When this event is triggered, we tell it to call the setContainerDims
method we defined, which looks like this:
setContainerDims() {
this.width = this.$el.clientWidth;
this.height = this.$el.clientHeight;
}
Here this.$el
refers to our app container, for which we probe the current width
and height
. Subsequently, we store these values on the main vue
instance.
Note that this
here refers to the vue instance (more on the this
context below).
Finally, we can use the beforeDestroy
hook to remove the event listener when the vue instance is about to be destroyed.
There are several other life cycle hooks that can be used. Vue provides the following life cycle hooks: beforeCreate
, created
, beforeMount
, mounted
, beforeUpdate
, updated
, beforeDestroy
and destroyed
.
In summary, as soon as we observe a window resize event
, the container main vue app is made aware of its new dimensions, which subsequently sends down a width prop
to the child components. In turn, the bubble chart components can react to the new prop values and redraw themselves.
Roughly, our bubble chart components looks like this:
Vue.component("bubble-chart", {
props: ["id", "width", "height", "data"],
...
mounted: function () {
this.svg = d3.select("#" + this.id);
},
methods: {
...
draw: function () {
...
}
},
...
template: `
<div class = "custom">
...
<svg :id="id" width="100%" :height="height" ref="svg"></svg>
</div>`
})
Initially, we ask vue to create a simple div
, containing an empty svg
element, using the template
option. This svg
element serves as our base container in which d3
will operate.
For convenience, when the chart gets mounted
to the DOM, we ask it to store a reference to the svg
element, using d3.select
and to store it on the vue component using this.svg
. The later operation provides us with an easy handle to the svg container, which comes in handy later.
Vue has a number of ways to listen and react to changes in data and or props. For instance, to create scales it's good to know the minimum and maximum values of the data that was passed to our component. For this we can use the d3
methods d3.min
and d3.max
. Furthermore, we can use vue's watch functionality to react to changes in our data:
watch: {
"data": {
handler: function (val) {
this.min = d3.min(this.data) - 20;
this.max = d3.max(this.data) + 20;
this.draw();
}
},
...
},
Here we told vue to watch for changes in data
. Whenever a change is detected, it runs our handler, which in our case sets the min
and max
values and stores them on our component. To create some extra room, we added a margin of 20, which will come in handy later. In addition, we ask vue to run our draw
method any time data
changes. Here draw
is a method we defined on our component that uses d3
to draw a bubble chart. Let's see how we can use d3
to create a nice draw
method.
Besides providing an svg
container element by vue.js
, we leave all the drawing operations of our charts to d3
as d3
is especially suitable to manipulate and generate svg content. The pseudo code or our draw function looks like this:
draw: function () {
// create a scale function
// bind data to our base svg: the data join step
// apply the d3 enter, update exit pattern
// to create, update and remove svg circle elements
}
We first create a scale function.
var x = d3
.scaleLinear()
.domain([this.min, this.max])
.range([0, this.width]);
In our case, we create a function x
to helps us to position the values in our data to horizontal pixel coordinates. Transforming input data to pixel data to position element on the screen is one of the most useful things d3
allow us to do, for which we often create a scale.
The domain
of our scale represents the extent of our input data, while range
represents the desired pixel range. In our case, we want this to be from the outer left of our chart i.e. 0
, to the the outer right i.e. to width
. Note, here x
represents a function, not a static value! This function can subsequently be used to interpolate values between the min
and max
values. For instance, if the domain would be [0,10] and the range would be [0,1600], then x(0) = 0
and x(10) = 1600
, while x(5) = 800
.
This step is often called the data join
. What does it 'join' you may ask? Well, in short it joins data the a selection
of DOM nodes. In our case the data join step looks like this:
var base = this.svg.selectAll("circle").data(this.data);
Likely, the data join is one of the most difficult and confusing steps in creating d3
based charts! Here we ask d3
to select
all the circle
elements in our svg element (if any). Furthermore, for each element in our data, it also creates a place holder, which in a later step we can use to create or update elements with. Note there are 3 possible scenarios:
scenario A] : we have less circle elements than we have individual data elements
scenario B] : we have more circle elements than we have individual data elements
scenario C] : we have as much circle elements as we have individual data elements
In the world of d3
, scenario A translates to the enter
step, scenario B translates to the exit
step and scenario C translates to the update
step.
to do ...
to do ...
to do ...
Note that data
in a vue context is reactive
. For instance, in our case we passed the width
data down to the child components via a prop
:
<div id="app" ref="app">
...
<bubble-chart ... :width="width" ...></bubble-chart>
<bubble-chart ... :width="width" ...></bubble-chart>
<bubble-chart ... :width="width" ...></bubble-chart>
</div>
Inside our bubble-chart
components we use watch
to listen for changes in the width
prop
:
Vue.component("bubble-chart", {
...
watch: {
...
"width": {
handler: function (val) {
this.debouncedDraw();
}
},
},
...
})
When this prop value changes, we call a handler, which in our case will special draw
function (debouncedDraw), that will redraw the bubble chart.
The bubble chart receives a number of other props
and creates a container svg
element in which d3
does it's thing.
Of note: besides creating the svg element, vue.js is not used to controll elements created inside the svg
container i.e. we are not using vue
for its virtual DOM, but mostly to encapsulate logic and for its client side reactivity system.
In the methods
part we define a suitable d3
based drawing function, simply called draw
, which does all the required drawing operations, which are heavily using d3
.
To limit the number of draw operations on resize events, we define a debounced version of the draw function in the created
life cycle hook.
For convenience, we store the a selection of the base svg container when the vue instance is mounted, which we can access via this.svg
, inside the vue instance.
During drawing we first bind all the data to the svg
container, after which we apply a standard enter
, update
and exit
d3
pattern. In the application, on data changes, green
bubbles indicate new elements created via enter
, blue
bubbles indicate updated elements, while exit
selections are shown as animated gray circles, that animate out of the screen, to both the left and right edges of the viewport.
Here we use the watch
functionality to listen for changes to the data and the width
prop, as passed down by the main app on resize events.
Vue.component("bubble-chart", {
props: ["id", "width", "height", "data"],
created: function () {
this.debouncedDraw = _.debounce(this.draw, 100);
},
mounted: function () {
this.svg = d3.select("#" + this.id);
},
methods: {
...
draw: function () {
...
// bind data to base svg
var base = this.svg
.selectAll("circle")
.data(this.data);
// update
// enter
// exit
}
},
...
watch: {
"data": {
handler: function (val) {
this.min = d3.min(this.data) - 20;
this.max = d3.max(this.data) + 20;
this.draw();
}
},
"width": {
handler: function (val) {
this.debouncedDraw();
}
},
},
template: `
<div class = "custom">
...
<svg :id="id" width="100%" :height="height" ref="svg"></svg>
</div>`
})
To showcase some technical desired behavior the charts automatically resize themselves whenever the container the are in resizes i.e. due to a browser window resize event
. Furthermore, the charts animate new data into position, while also removing old data in an animated way.
In order to do these we follow the well known enter, update, exit pattern by d3. See here for more info.
Last, the charts react to on click
, on hoverover
and on hoverout
events when interacting with the circle element. In such cases the charts 'emit' events to the parent, the main vue instance.
The main purpose of this example is to demonstrate how we can encapsulate complex logic of a d3
driven chart into a vue component. In that sense we are less interested here in the templating abilities of vue or it's performance benefits in terms of using a virtual DOM to control appearance.
Inside the draw
method of the bubble chart we use a custom emit
method to send data to shiny via the event bus. Note that inside d3
functions (when we pass the data attribute d
), we have a different this
context than outside of the d3
scope. Hence, sometimes we temporarily use let vm = this
to store the vue instance this
context so we can call vue methods inside d3
functions.
When drawing, during the enter selection we attach various event listeners i.e. for click
, mouseover
and mouseout
events.
The application uses a simple event bus to communicate messages between the components. The message bus is injected into every component via:
Object.defineProperties(Vue.prototype, {
$bus: {
get: function() {
return EventBus;
}
}
});
...
// send messages to parent (main vue instance) from bubble component
emit: function (type, data, index) {
let payload = { "id": this.id, "event": type, "data": data, "index": index };
let event_name = "bubble-event";
this.$emit(event_name, payload);
},
...
draw: function () {
let vm = this;
...
// bind all data to svg container
var base = this.svg
.selectAll("circle")
.data(this.data);
...
// d3 enter selection. We attach event listeners here
base.enter()
.append("circle")
...
.on("click", function (d, i) {
vm.emit("click", d, i);
})
.on("mouseover", function (d, i) {
...
vm.emit("mouseover", d, i);
})
.on("mouseout", function (d, i) {
...
vm.emit("mouseout", d, i);
})
...
}
In the parent we can use the v-on
directive to listen for these events. For instance, the code segment v-on:bubble-event="showMessage"
is saying: "when the bubble chart emits an bubble-event
event, call the showMessage
method on the parent (the main vue instance) and pass whatever payload
the bubble chart send upward. You can think of the payload as some data object holding the data that we want to pass upward.
As indicated above, on hover or click events, the bubble charts emit events. Each event we emit has a name e.g. bubble-event
. Remember, in a vue context props
are used to send information down, while we send information upward in the component tree by emitting
events.
Note that the emit
method on the vue bubble component emits events to its parent, the main vue app, under the name bubble-event
.
In the main app, we can capture these events, by using the v-on
directive:
<div id="app" ref="app">
...
<bubble-chart v-on:bubble-event="showMessage" ..."></bubble-chart>
<bubble-chart v-on:bubble-event="showMessage" ..."></bubble-chart>
<bubble-chart v-on:bubble-event="showMessage" ..."></bubble-chart>
The code segment v-on:bubble-event="showMessage"
is saying, when the bubble chart emits an event with name bubble-event
, call the showMessage
method on the parent (the main vue instance) and pass whatever payload the bubble chart passed over.
In the main app we define the showMessage
method:
var app = new Vue({
el: "#app",
...
methods: {
...
showMessage(payload) {
...
// send data from main vue instance to event bus
// in turn, the event bus sends data to shiny
this.$bus.$emit("shiny", { "input_id": "bus", "data": { "id": "bus", "data": payload } });
}
},
...
})
In our case the main vue app emits a shiny
event and sends an object holding the original payload, augmented with some info that tells shiny on what input_id
it can expect new data.
Finally, when our event bus receives a message on emitted with the shiny
tags (as done by our main app), it calls it's own method 'shiny_emits', which uses a standard Shiny.onInputChange
command to send data to shiny. In this case $event
is the payload send along when emitted the shiny
event, which contains an object that has a input_id
key and a data
key.
// event bus
const EventBus = new Vue({
created() {
...
// listen for events to send data to shiny
this.$on("shiny", this.shiny_emit);
},
methods: {
...
// send data to shiny
shiny_emit($event) {
if (typeof Shiny !== "undefined") {
Shiny.onInputChange($event.input_id, $event.data);
}
}
}
});
On the R
side of things we can capture these events when listening to input$bus
inside an observe block:
shinyServer(function(input, output, session) {
# listen for data send from browser via event bus
output$bus <- renderUI({
req(input$bus)
tagList(
fluidRow( id = "shiny_result",
column(2, h5("received by shiny from vue")),
column(2, h5("type:"), input$bus$id),
column(2, h5("component id:"), input$bus$data$id),
column(2, h5("event:"), input$bus$data$event),
column(2, h5("data:"), input$bus$data$index),
column(2, h5("item index:"), input$bus$data$data)
)
)
})
...
})
The app can send data from the vue components / vue app to shiny, but can also update data from shiny to the client.
Using session$sendCustomMessage
we can send data to vue:
shinyServer(function(input, output, session) {
# on updateVue, send some new data to the browser
observeEvent(input$updateVue,{
# number of items to generate
n1 <- floor(runif(1, min=1, max=50))
n2 <- floor(runif(1, min=1, max=50))
n3 <- floor(runif(1, min=1, max=25))
# data object to send to Javascript
payload <- list(
# bubble data
data = list(
bubble_data1 = rnorm(n1, mean=100, sd=100),
bubble_data2 = rnorm(n2, mean=100, sd=100),
bubble_data3 = rnorm(n3, mean=100, sd=100)
)
)
# run shiny handler on Javascript with name shiny_update_vue
# and pass it our payload
session$sendCustomMessage("shiny_update_vue", payload)
})
})
For simplicity, on the Javascript side, we use a custom message handler to pass data directly into the main app.
if (typeof Shiny !== "undefined") {
Shiny.addCustomMessageHandler("shiny_update_vue", function(payload) {
app.bubble_data1 = payload.data.bubble_data1;
app.bubble_data2 = payload.data.bubble_data2;
app.bubble_data3 = payload.data.bubble_data3;
});
}
Please see the code base for details on how to create a bubble chart via d3
and for the remainder of various methods and other functions.