Realtime Chart in d3.js

Recently I was interested in making a realtime chart in d3 and had a hard time finding a simple tutorial on how to do it. This post is the tutorial I wish I was able to find.

This post will show how to make a simple realtime chart in d3 that you can extend. It assumes you know the basics of d3.

We will end up with a plot that shows a random walk where both the x and y axis update to reflect new data.

Setup

First, we need to set up the elements that will contain the axis and plot. We then reuse these elements on each update. We create the containing graph element, a group for the actual plot, the y-axis and the x-axis.

function initialize(width, height){
    var graph = d3.select("#plot")
              .append("svg")
              .attr("width", width)
              .attr("height", height);

    var barGroup = graph.append("g");

    var xScaleGroup = graph.append("g");

    var yScaleGroup = graph.append("g");

    return [graph, barGroup, xScaleGroup, yScaleGroup]
}
const width = document.getElementById('plot').offsetWidth;
const graphVars = initialize(width, width*0.7);

Render function

Now that we have these components initialized, we can use them to render the data we have available. Our data will be in an array, where each element has a date and value. The x-axis will be between now and some lookback from now. This means points older than the lookback need to be removed. The y-axis will rescale based on the points available.

First, we will do a little bookkeeping, setting up the const we will need.

function render(data, now, lookback, graphVars){
    const room_for_axis = 40;

    const [graph, barGroup, xScaleGroup, yScaleGroup] = graphVars;

    const radius = graph.attr('width')/200.0;

    const xValues = data.map(a => a[0]);
    const yValues = data.map(a => a[1]);

    const miny = d3.min(yValues)
    const maxy = d3.max(yValues)
    const range = maxy-miny

    ...
}

Next, we need to set up the scales so that we can correctly set the points center.

    const xScale = d3.scaleTime()
                    .domain([lookback, now])
                    // Add a little extra room for y axis
                    .range([room_for_axis+5, graph.attr('width')]); 

    const yScale = d3.scaleLinear()
                     .domain([miny-range*0.01, maxy+range*0.01])
                    // reverse range so axis goes from low to high read bottom to top
                     .range([graph.attr('height')-room_for_axis, 0]);


Next, we remove points that are older than the lookback.

    const to_remove = data.filter(a => a[0] < lookback);
    barGroup.selectAll("circle")
            .data(to_remove)
            .exit()
            .remove();

We then add new points. We need to translate all the points, even ones that have already been created because both the x and y axis are changing.

    data = data.filter(a => a[0] > lookback);
    barGroup.selectAll("g")
            .data(data)
            .enter()
            .append("circle");

    barGroup.selectAll("circle")
        .attr('cx', function(d){ return xScale(d[0])})
        .attr("cy", function(d) { return yScale(d[1])})
        .attr("r", radius);

And finally we update the visual scales.

    var x_axis = d3.axisBottom().scale(xScale);
    xScaleGroup.attr('transform', 'translate(0,' + (graph.attr('height') - room_for_axis) + ')')
              .call(x_axis);

    var y_axis = d3.axisLeft().scale(yScale)
    yScaleGroup.attr('transform', 'translate(' + room_for_axis + ',0)').call(y_axis);

Render loop

Finally we need to call our render function periodically, updating the time range and adding any new points that have arrived since the last rendering.

function new_point(last_point, scale){
    walk = (Math.random() - 0.5) * scale
    return last_point + walk
}

const updateIntervalMs = 200;
setInterval(function () {
    // Add a new point
    last_point = new_point(last_point, scale);
    data.push([now, last_point]);

    // Move time forward
    now = new Date()
    lookback = new Date(now)
    lookback.setSeconds(lookback.getSeconds() - lookback_s);

    data = render(data, now, lookback, graphVars);
}, updateIntervalMs);

Wrapping up

Putting it all together, we have a realtime chart! Below, is the full code used on this page to initialize and render the plot above.

<script>
function initialize(width, height){
    var graph = d3.select("#plot")
              .append("svg")
              .attr("width", width)
              .attr("height", height);

    var barGroup = graph.append("g");

    var xScaleGroup = graph.append("g");

    var yScaleGroup = graph.append("g");

    return [graph, barGroup, xScaleGroup, yScaleGroup]
}


function render(data, now, lookback, graphVars){
    const room_for_axis = 40;

    const [graph, barGroup, xScaleGroup, yScaleGroup] = graphVars;

    const radius = graph.attr('width')/200.0;

    const xValues = data.map(a => a[0]);
    const yValues = data.map(a => a[1]);

    const xScale = d3.scaleTime()
                    .domain([lookback, now])
                    // Add a little extra room for y axis
                    .range([room_for_axis+5, graph.attr('width')]); 

    const miny = d3.min(yValues)
    const maxy = d3.max(yValues)
    const range = maxy-miny
    const yScale = d3.scaleLinear()
                     .domain([miny-range*0.01, maxy+range*0.01])
                     .range([graph.attr('height')-room_for_axis, 0]);

    const colorScale = d3.scaleTime()
                    .domain([lookback, now])
                    .range(['blue', 'red']); 

    const to_remove = data.filter(a => a[0] < lookback);
    barGroup.selectAll("circle")
            .data(to_remove)
            .exit()
            .remove();

    data = data.filter(a => a[0] > lookback);
    barGroup.selectAll("g")
            .data(data)
            .enter()
            .append("circle");

    barGroup.selectAll("circle")
        .attr('cx', function(d){
            return xScale(d[0]);
        })
        .attr("cy", function(d) {
                return yScale(d[1]);
       })
        .attr("r", radius)
        .attr("fill", function(d){ return colorScale(d[0])});
       
    var x_axis = d3.axisBottom().scale(xScale);
    xScaleGroup.attr('transform', 'translate(0,' + (graph.attr('height') - room_for_axis) + ')')
              .call(x_axis);

    var y_axis = d3.axisLeft().scale(yScale)
    yScaleGroup.attr('transform', 'translate(' + room_for_axis + ',0)').call(y_axis);

    return data
}

function new_point(last_point, scale){
    walk = (Math.random() - 0.5) * scale
    return last_point + walk
}

const scale = 0.2;
const lookback_s = 30;

// initialize
var now = new Date();
const width = document.getElementById('plot').offsetWidth;
const graphVars = initialize(width, width*0.7);

var data = [];
var last_point = Math.random();
data.push([now, last_point]);

var lookback = new Date(now)
lookback.setSeconds(lookback.getSeconds() - lookback_s);
now = new Date();
render(data, now, lookback, graphVars);

const updateIntervalMs = 200;
setInterval(function () {
    // Add a new point
    last_point = new_point(last_point, scale);
    data.push([now, last_point]);

    // Move time forward
    now = new Date()
    lookback = new Date(now)
    lookback.setSeconds(lookback.getSeconds() - lookback_s);

    data = render(data, now, lookback, graphVars);
}, updateIntervalMs);

</script>

Iā€™m still pretty new to d3 and javascript, if you find any coding patterns odd in this write up, please let me know so I can learn and correct it!

Written on September 19, 2020