D3 is one of the most widely used JavaScript chart library out there. It is free, open-source, and while it may be daunting at first, it provides unlimited customisation for your interactive data visualisations.
I have taught it for many years now. We usually have to accommodate for a variety of experiences from students and teach using examples they have to complete, but some of the more hands-on learners sometimes need to do things by themselves from start to finish.
While I was not too sure what to tell them at first, I realise over time that a great way to play with D3 for beginners is to make bar charts.
It may seem trivial at first (and compared to other charts, it is), but making a bar chart in D3 actually lets you explore quite the number of key concepts for you to progress further. So let's get started.
What we want to achieve
Normally we would match the chart to the type of data we are given, not the other way around. But this is a tutorial about bar charts so we will have to work in reverse just for now.
Bar charts typically show elements with two attributes: a category, or key, and a value used to compare categories (check this post from the Data Visualisation Catalogue).
So let's imagine you are given this data:
const data1 = [{key: 'A', value: 30},{key: 'B', value: 20},
{key: 'E', value: 50},{key: 'F', value: 80},
{key: 'G', value: 30},{key: 'H', value: 70},
{key: 'J', value: 60},{key: 'L', value: 40}];
Our goal is to map it onto a set of rectangles, spread across vertically, with their width scaling to the value attribute.
Setup
We will start by making a simple HTML page, where we load D3's library and add one title and a div
:
<!DOCTYPE html>
<html>
<head>
<title>D3 Bar Chart</title>
<script type="text/javascript" src="https://d3js.org/d3.v6.min.js"></script>
<style type="text/css">
/* our custom styles */
</style>
</head>
<body>
<h1>D3 Bar Chart Example</h1>
<div id="barContainer"></div>
<script type="text/javascript">
const data1 = [{key: 'A', value: 30},{key: 'B', value: 20},
{key: 'C', value: 60},{key: 'D', value: 40},
{key: 'E', value: 50},{key: 'F', value: 80},
{key: 'G', value: 30},{key: 'H', value: 70}];
const width = 600, height = 400, margin = {t:10,b:30,l:30,r:10};
</script>
</body>
</html>
Note: We are working with version 6 of D3.
We have also added our data and a set of values for our chart's dimensions. We will reuse these values multiple times, so we better save them in constants.
We are all set up here, and we know what we want to do. Let's create our bar chart with D3 now.
Selections
To understand what D3 is, it is always useful to remind ourselves of what it stands for:
Data Driven Documents.
The title says it all, it is a library that lets us manipulate the Document Object Model (DOM) of HTML pages using data. And the D3 way to do that is with Selections. To make it simple, selections are like wrappers for DOM elements, giving us an API to program these elements (there is more to it, but we will get there in a bit).
Say we want to add an SVG element to our div
using D3. The way to do this is to select the parent (the div
element) and append an svg
element to it. And because the append method returns the newly created element selection, we can use it to set our chart's dimensions and save that selection into a variable.
const svg = d3.select('div#barContainer') // use the same css selectors to target your selections
.append('svg') // our selection now maps to a newly created svg
.attr('width', width) // the .attr() method lets you set the attribute value of your element
.attr('height', height)
.style('border', 'solid 1px #222'); // the .style() method lets you set the style of you element
The code above does just that, select our container adds an SVG to it, and saves the new selection in a variable svg
. It does two additional things. With the .attr()
method, we set the SVG's width and height using the values we defined previously and with the .style()
method, we give a style to the SVG's border.
In fact, if we run our code in a browser, it displays the following:
And inspecting our DOM, here is what you should get:
I recommend using a local server to that, for example, python's or a node server
Logically, this new SVG is the root element of our bar chart and saving its selection in a variable means we can access it across our code easily. For example, let's define a chart area, where we will later draw our bars:
const chart = svg.append('g') // add a group to the svg
.classed('chart', true) // give our svg group a class name
.attr('transform', `translate(${margin.l},${margin.t})`); // set the transform attribute to translate the chart area and set margins
Here we use a new method, .classed()
as a way to set a class name for this SVG group. You use the same method to remove a class from an element, by putting false
as your second parameter. You could technically use .attr('class', ...)
, but beware of this, since it will replace the whole value for the attribute class
. The method .classed()
remains the safest way to add/remove classes from elements.
We have also translated this group, using the margin values. Now, any element appended to this group will be drawn from a new reference point.
Quick Summary of selection methods
There are many methods you can use with selections, and I encourage you to take a look at the API for more details. But for now, here is a quick summary of what we have seen so far.
Method | Description |
---|---|
d3.select(selector) |
Creates a selection with the first element matching the selector's criteria |
selection.select(selector) |
Creates a new selection with the first child element matching the selector's criteria |
selection.append(element) |
Adds a child element and returns it as a new selection |
selection.attr(name, value) |
Sets the attribute name for the elements mapped in the selection |
selection.style(name, value) |
Sets the style name for the elements mapped in the selection |
selection.classed(name, bool) |
Adds or remove a class name to the elements mapped in the selection |
Bind, Enter, Exit, Update: the General Update Pattern
So far, what we have seen about selections is pretty basic, and you might be thinking that it is probably not worth using a whole library for that.
But we have only just scratch the surface. Remember that D3 stands for Data Driven Documents.
Binding data
Where D3's selections become truly useful is with data binding. In essence, this makes the selection a bridge between your data and the DOM.
We do so by calling the .data()
selection method:
javascript
let bars = chart.selectAll('rect.bar'); // from chart, select all rect element with class bar in
bars = bars.data(data1, d=>d.key); // bind data to the bars and save the selection
The .selectAll()
method is similar to the .select()
we have seen before. But instead of selecting the first DOM element matched by the selector, .selectAll()
selects all the elements that matched. In this instance, it is all the SVG rectangles, with the class bar
, children of our chart SVG group.
Then, the .data()
method binds our data to the selection. The method's second parameter is what we call the key function, it is used to identify the data entry and create a unique link with the selection entry.
At this stage however, you might be wondering: where are all this SVG rectangles?. And you would be right, we have not created them yet. But we will use D3 to build exactly what we need.
Updating the DOM to match the dataset
When you bind data to a selection, the .data()
method returns a new version of the selection, where its entries are separate in three categories: the new, the old, and the obsolete.
The new
The new are data entries the selection has no DOM element to match with (according to the key function). This is referred to as the enter selection and is accessed with the .enter()
method.
// the new, create the element from scratch
bars.enter().append('rect')
.classed('bar', true)
.attr('x', 0)
.attr('y', (d,i)=>i*35)
.attr('height', 30)
.attr('width', d=>d.value*6);
Because these rectangles are new, we have to create them (.append()
) and set all of their attributes/style.
For some of these attributes, you will notice we did not use a fixed value as we did before. Because we bound our data to them, we can customise their look to fit with the data. That is where we can drive our document from the data and create awesome charts! Essentially you can now use functions to decide on the value of your attributes (or style). These functions have three parameters: the element's datum d
, the element's index i
, and the group the element is part of nodes
.
Here we set the rectangles' positions to align them on the left (x = 0
) and distribute them vertically using the elements' indices (y(d,i) = i*35
). We also set the rectangles' sizes to a fixed height (height = 30
) and a width function of the data value (width(d) = d.value*6
).
And like that, we have bars, straight from the data we were "given" earlier.
The old
But let's finish touring our sub-selection. While we have not faced such case yet, it could be that the chart's elements you are currently drawing already exist, and used an older version of the data.
The second sub-selection, the old, are data-DOM links the selection used to have and which are still there (again, according to the key function), but with possibly new values. This is sometimes referred to as the update selection. You do not need a specific method to access it, just the selection variable.
// the old, just update the bar position and length
bars.attr('y', (d,i)=>i*35)
.attr('width', d=>d.value*6);
Here, we just change what is dependent on the data: the vertical position of the bar and its length.
The obsolete
Finally, the obsolete are DOM elements the selection has no data to attach to anymore (you guessed it, according to the key function). This is referred to as the exit selection and is accessed with the .exit()
method.
bars.exit().remove();
Here, we simply use the .remove()
method to delete the rectangles which are not needed anymore.
The General Update Pattern
What we have just seen constitutes D3's General Update Pattern. It is a process typically followed when updating your charts:
- Bind the data
- Create the enter selection
- Remove the exit selection
- Update the selection's old entries
It is often a good idea to wrap it in a function, where you just need to give a dataset, and your script will draw the new or updated chart:
function updateData(dataset){
// make our selection
let bars = chart.selectAll('rect.bar');
// bind data
bars = bars.data(dataset, d=>d.key);
// create the new
bars.enter().append('rect')
.classed('bar new', true)
.attr('x', 0)
.attr('y', (d,i)=>i*35)
.attr('height', 30)
.attr('width', d=>d.value*6);
// remove the obsolete
bars.exit()
.classed('obs', true)
.remove();
// update the old
bars.classed('new', false)
.attr('y', (d,i)=>i*35)
.attr('width', d=>d.value*6);
}
Notice how I added a class new
to the new elements, obs
to the obsolete elements, and removed the new
class for old ones. We can use it to see which rectangles are new when the chart updates:
svg > g.chart > rect.bar{
fill: steelblue;
stroke-width: 1px;
stroke: #444;
}
svg > g.chart > rect.bar.new{
fill: seagreen;
}
svg > g.chart > rect.bar.obs{
fill: tomato;
}
Now, we are repeating ourselves with the enter and update selections, and from a programming point of view, this is not quite right. Since they will be the same for both selections, we should be setting the rectangles' position and width all at once, which is possible thanks to the .merge()
method:
function updateData(dataset){
// make our selection
let bars = chart.selectAll('rect.bar');
// bind data
bars = bars.data(dataset, d=>d.key);
// create the new and save it
let barsEnter = bars.enter().append('rect')
.classed('bar new', true)
.attr('x', 0)
.attr('height', 30);
// remove the obsolete
bars.exit()
.classed('obs', true)
.remove();
// update old alone
bars.classed('new', false);
// merge old and new and update together
bars.merge(barsEnter)
.attr('y', (d,i)=>i*35)
.attr('width', d=>d.value*6);
}
Setting attributes for the enter and update selection is actually the 5th optional step of the General Update Pattern. We can now use this update function to render and update our bar chart:
// assume a second set of data, updating data1
const data2 = [{key: 'A', value: 40},{key: 'C', value: 20},
{key: 'D', value: 10},{key: 'F', value: 50},
{key: 'G', value: 60},{key: 'H', value: 90},
{key: 'I', value: 10},{key: 'J', value: 30},
{key: 'K', value: 50},{key: 'L', value: 80}];
// calling our update function
setTimeout(()=>{updateData(data1)}, 1000);
setTimeout(()=>{updateData(data2)}, 5000);
It's alive!! However, the update is not really salient. But do not worry, we can use transitions for this.
Quick Summary of selection methods
Again, here is a recap of the methods we have seen in this section.
Method | Description |
---|---|
d3.selectAll(selector) |
Creates a new selection with all the elements matching the selector's criteria |
selection.selectAll(selector) |
Creates a new selection with all the children elements matching the selector's criteria |
selection.data(dataset, keyFunction) |
Binds data to the selection |
selection.enter() |
Accesses the enter selection |
selection.exit() |
Accesses the exit selection |
selection.remove() |
Removes elements of the selection from the DOM |
selection.merge(selection2) |
Merges selections together |
Animating your chart
You would have guessed it, D3 also provides us with means to add animations to our chart. They are particularly useful to transition between your charts' updates to check what is exactly happening. As such, D3 conveniently named this concept Transitions.
Now, back to our update function. We will need three different transitions in the following order:
- removing the exit selection;
- positioning the enter and update selections;
- adjusting the length of the enter and update selections.
const tRemove = d3.transition();
const tPosition = d3.transition();
const tSize = d3.transition();
The API of transitions is quite similar to the selections one. One difference however, is that it provides methods for timing the animations. The most important ones being .duration()
to set the animation span, and .delay()
to postpone the animation start. Using these methods, we can customise our transitions:
const d = 500; // our base time in milliseconds
const tRemove = d3.transition()
.duration(d); // 500ms duration for this animation
const tPosition = d3.transition()
.duration(d)
.delay(d); // 500ms wait time before this animation starts
const tSize = d3.transition()
.duration(d)
.delay(d*2); // 1000ms wait time before this animation starts
In the code above we are essentially creating 3 transitions that will animate our selections for 500ms, but should be launched one after the other. Note that the default value for durations is 250ms and 0ms for delays.
Next, we need to add these transition in our update pattern:
// ...
// remove the obsolete
bars.exit()
.classed('obs', true)
.transition(tRemove) // remove transition
.attr('width', 0) // animate the length to bars to 0
.remove(); // delete the rectangles when finished
// ...
// merge old and new and update together
bars.merge(barsEnter)
.transition(tPosition) // position transtition
.attr('y', (d,i)=>i*35) // align all rectangles to their vertical position
.transition(tSize) // size transition
.attr('width', d=>d.value*6); // set the rectanble sizes
As you can see we use the .transition()
method to apply the predefined transitions to our selections. Note that once a transition applied, the chained methods (.attr()
for example) are transition methods. As such, they may behave differently: .remove()
, for example, only deletes elements when the transition ends.
For the same reason, transitions do not work with the .classed()
method. And since we are using classes to style your chart (which I strongly recommend for global styles), it is best to add the appropriate CSS transitions:
svg > g.chart > rect.bar{
fill: steelblue;
stroke-width: 1px;
stroke: #444;
transition: fill 300ms;
}
And then call the .classed()
method outside of transitions, using a timeout. Adding the following at the end of our function will return the bars to their default style once the update is complete:
setTimeout(()=>{bars.merge(barsEnter).classed('new', false)}, d*4)
And just like that, we have got a complete update transition, which makes it easier to follow what is happening.
Next, we will see how to better manage our chart area.
Quick Summary of transition methods
Here are the transition methods we have seen in this section, and what are probably the most common ones.
Method | Description |
---|---|
d3.transition() |
Creates a new transition |
transition.duration(value) |
Sets the duration (in milliseconds) of the transition |
transition.delay(value) |
Sets the delay (in milliseconds) before the transition can start |
selection.transition(t) |
Applies transition t to your selection |
Scaling our charts to the view
So far, we have been setting our bar height with an arbitrary value (30), from which we had to infer the space between the bars (35 = 30 bar height + 5 spacing). Similarly, we have arbitrarily decided that the bars' length will be a product of 6. All of that worked okay so far, but as we have seen, any data update could suddenly change the number of entries or the maximum value, which makes our arbitrary decisions impractical.
We could be all fancy and come up with ways to automatically compute, with every new dataset, what value we should use. Or we could use D3's Scales.
These scales have one simple task, mapping a domain to a range, but come with a lot of perks. Typically, you would use them to map from your data domain to your view range, which is what we will do now. They are many scales available, but we will look at two in particular: the continuous-linear scale, and the ordinal-band scale.
Getting the correct length of bars
The first scale we will look at is the continuous linear scale. This is the most forward scale, as the name suggests, it simply maps, linearly, a continuous domain to a continuous range.
It is the perfect tool to ensure that our bars are always contained within our chart view while keeping the ratio between bar lengths correct, after all, that is the point of bar charts.
To use it, we will simply create an instance of linear scale, and set the boundaries of its domain and range:
const xScale = d3.scaleLinear()
.domain([0, d3.max(dataset, d=>d.value)])
.range([0, width-margin.l-margin.r]);
With this scale, we keep the same origin 0, however, we match the maximum value from our dataset with the maximum length possible (the width minus horizontal margins). To get the maximum dataset value, I have used one D3's Array methods, .max()
, by providing it with the appropriate accessor function.
We can now use this scale to scale our bars so that they always fit in length:
// ...
// create the new and save it
let barsEnter = bars.enter().append('rect')
.classed('bar new', true)
.attr('x', xScale(0)) // in case we change our origin later
.attr('height', 30);
// ...
// merge old and new and update together
bars.merge(barsEnter)
.transition(tPosition)
.attr('y', (d,i)=>i*35)
.transition(tSize)
.attr('width', d=>xScale(d.value)); // scaling the bar length
}
Spreading the bars evenly
The second scale we will look at is an ordinal band scale: our domain is categorical (no longer continuous) but our range remains continuous. Essentially it divides our range into even bands and map them to the categories in our domain.
It will allow us to always position the bars vertically and given the appropriate height, no matter the number of entries in the data.
Like linear scales, we just need to create an instance of it and define its range boundaries. Unlike linear scales, we have to provide the whole domain:
const yScale = d3.scaleBand()
.domain(dataset.map(d=>d.key))
.range([0, height-margin.t-margin.b])
.padding(0.2);
This scale's range goes from 0 to the height of the chart minus vertical margins. The .padding()
method lets us define the space (in proportion) between the bands.
Next, we can add it to our update process:
// ...
// create the new and save it
let barsEnter = bars.enter().append('rect')
.classed('bar new', true)
.attr('x', xScale(0)); // in case we change our origin later
// ...
// merge old and new and update together
bars.merge(barsEnter)
.transition(tPosition)
.attr('y', d=>yScale(d.key)) // scaling the bar position
.attr('height', yScale.bandwidth()) // using the computed band height
.transition(tSize)
.attr('width', d=>xScale(d.value)); // scaling the bar length
Note that we have moved the height definition to the position animation and used the .bandwidth()
method to get the computed height from the scale.
And that is all there is to it. Just a few lines of code and we have got bars that are perfectly fitted within their chart.
There are two important components missing to finish our bar chart: axes! But since we have used D3's scales, you will see that axes are going to be a piece of cake.
Quick Summary of scale methods
I have recap below the scale methods we saw in this section. But I encourage you to have a look at D3's API and see how much you can do with scales.
Method | Description |
---|---|
d3.scaleLinear() |
Creates a new linear scale |
linearScale.domain([min, max]) |
Sets the domain boundaries of a linear scale |
linearScale.range([min, max]) |
Sets the range boundaries of a linear scale |
d3.scaleBand() |
Creates a new band scale |
bandScale.domain(array) |
Sets the domain of a band scale |
bandScale.range([min, max]) |
Sets the range boundaries of a band scale |
bandScale.padding(value) |
Sets the padding between bands for a band scale |
bandScale.bandwidth() |
Returns the computed band size of a band scale |
d3.max(data,accessor) |
Returns the maximum value of a dataset according to the accessor function |
Don't forget the axes!
Axes and labels are ones of the most crucial elements of data visualisations. Without them, your visualisation loses all its context, making it essentially useless. That is why D3 has an integrated an Axis module which works seamlessly with scales.
To include these, we first need to define a space for them, adding two groups to our svg:
const xAxis = svg.append('g')
.classed('axis', true)
.attr('transform', `translate(${margin.l},${height-margin.b})`);
const yAxis = svg.append('g')
.classed('axis', true)
.attr('transform', `translate(${margin.l},${margin.t})`);
Next, in our update process, we need to change these group selections to render an updated axis:
d3.axisBottom(xScale)(xAxis.transition(tSize));
d3.axisLeft(yScale)(yAxis.transition(tPosition));
And that is it. D3 axes were made to render D3 scales, and that is what the code above does. To break it down, d3.axisBottom(xScale)
creates a new axis, based on xScale
, to be rendered with its ticks downwards. We then directly call this axis on the xAxis
selection defined before. And the same goes with d3.axisLeft(yScale)
(the ticks are directed towards the left). Note that we also applied our transitions to sync the axis change with the bar change.
Quick Summary of axes methods
Like scales, there is a lot more in D3's API, but here are the methods we have used in this section.
Method | Description |
---|---|
d3.axisBottom(scale) |
Creates a new bottom axis based on scale
|
d3.axisLeft(scale) |
Creates a new left axis based on scale
|
axis(selection) |
Renders the axis within the provided selection |
Bonus: Adding interactivity
Interactivity is one of the greatest advantages of browser-based data visualisations. Mousing over the element of one chart can highlight the corresponding element(s) in a second coordinated chart or display a tooltip with more information for context, you can also use clicks on one view to filter data in another view, etc.
It is no surprise then, that D3 added event listeners to its selections. Let's imagine we want to apply a highlight class to our bars when you mouse over it.
svg > g.chart > rect.bar.highlight{
fill: gold;
stroke-width: 4px;
}
We can do so with the .on()
selection method, which takes two parameters: the event name to listen for, and the callback function to apply. We just need to apply these listeners to our enter selection (they will remain after an update).
//...
let barsEnter = bars.enter().append('rect')
.classed('bar new', true)
.attr('x', xScale(0))
.on('mouseover', function(e,d){
d3.select(this).classed('highlight', true);
})
.on('mouseout', function(e,d){
d3.select(this).classed('highlight', false);
});
//...
There are two things to note here. First, we have not used an arrow function like other callbacks, that is because we want to have access to the caller's scope (the element moused over) and use its this
to select only the element and apply our class change. Second, the callback does not have the typical parameters (data and index), instead, it uses event and data.
We have added listeners to two events: mousover
for the cursor enters the element and mouseout
for when it exits.
Conclusion
That is it for this tutorial. From just the simple goal of creating a bar chart, we have explored many concepts core to using D3:
- Selections
- the General Update Pattern
- Transitions
- Scales and Axes
- Events
There is of course a lot more to D3 than that: data manipulation, layout generators (pies, Voronoi, chords, etc.), geographical maps, colour scales, time and number formatting, complex interactions (brushing, zooming, dragging, forces, etc.), complex transitions. But, hopefully, this tutorial has given you the desire to go further.
Here is the complete code I have used.
<!DOCTYPE html>
<html>
<head>
<title>D3 Bar Chart</title>
<script type="text/javascript" src="https://d3js.org/d3.v6.min.js"></script>
<style type="text/css">
svg{
border: solid 1px #222;
}
svg > g.chart > rect.bar{
fill: steelblue;
stroke-width: 1px;
stroke: #444;
transition: fill 300ms;
}
svg > g.chart > rect.bar.new{
fill: seagreen;
}
svg > g.chart > rect.bar.obs{
fill: tomato;
}
svg > g.chart > rect.bar.highlight{
fill: gold;
stroke-width: 4px;
}
</style>
</head>
<body>
<h1>D3 Bar Chart Example</h1>
<div id="barContainer"></div>
<script type="text/javascript">
// datasets
let data1 = [{key: 'A', value: 30},{key: 'B', value: 20},
{key: 'E', value: 50},{key: 'F', value: 80},
{key: 'G', value: 30},{key: 'H', value: 70},
{key: 'J', value: 60},{key: 'L', value: 40}];
let data2 = [{key: 'A', value: 40},{key: 'C', value: 20},
{key: 'D', value: 10},{key: 'F', value: 50},
{key: 'G', value: 60},{key: 'H', value: 90},
{key: 'I', value: 10},{key: 'J', value: 30},
{key: 'K', value: 50},{key: 'L', value: 80}];
// chart dimensions
let width = 600, height = 400, margin = {t:10,b:30,l:30,r:10};
// svg element
let svg = d3.select('div#barContainer')
.append('svg')
.attr('width', width)
.attr('height', height)
.style('border', 'solid 1px #222');
// chart area
let chart = svg.append('g')
.classed('chart', true)
.attr('transform', `translate(${margin.l},${margin.t})`);
// axes areas
let xAxis = svg.append('g')
.classed('axis', true)
.attr('transform', `translate(${margin.l},${height-margin.b})`);
let yAxis = svg.append('g')
.classed('axis', true)
.attr('transform', `translate(${margin.l},${margin.t})`);
// update function
function updateData(dataset){
// transitions
let d = 500;
let tRemove = d3.transition()
.duration(d);
let tPosition = d3.transition()
.duration(d)
.delay(d);
let tSize = d3.transition()
.duration(d)
.delay(d*2);
// scales
let xScale = d3.scaleLinear()
.domain([0, d3.max(dataset, d=>d.value)])
.range([0, width-margin.l-margin.r]);
let yScale = d3.scaleBand()
.domain(dataset.map(d=>d.key))
.range([0, height-margin.t-margin.b])
.padding(0.2);
// axes
d3.axisBottom(xScale)(xAxis.transition(tSize));
d3.axisLeft(yScale)(yAxis.transition(tPosition));
// update pattern
// initial selection
bars = chart.selectAll('rect.bar');
// data binding
bars = bars.data(dataset, d=>d.key);
// exit selection
bars.exit()
.classed('obs', true)
.transition(tRemove)
.attr('width', 0)
.remove();
// enter selection
let barsEnter = bars.enter().append('rect')
.classed('bar new', true)
.attr('x', xScale(0))
.on('mouseover', function(e,d){
d3.select(this).classed('highlight', true);
})
.on('mouseout', function(e,d){
d3.select(this).classed('highlight', false);
});
// update selection
bars.classed('new', false);
// enter + update selection
bars.merge(barsEnter)
.transition(tPosition)
.attr('y', d=>yScale(d.key))
.attr('height', yScale.bandwidth())
.transition(tSize)
.attr('width', d=>xScale(d.value));
// class reset
setTimeout(()=>{bars.merge(barsEnter).classed('new', false)}, d*4)
}
setTimeout(()=>{updateData(data1)}, 2000)
setTimeout(()=>{updateData(data2)}, 6000)
</script>
</body>
</html>
Top comments (0)