How to convert D3.js datamap to an animated gif

BeamToIX has its own charts component, but it can also be used to create frame-by-frame animations of other chart and map libraries.
D3.js has been widely used to create interactive animations for data science, but since its animation engine is mainly designed to be used interactively, if want you to generate the animation frames to generate animated gifs or movies by using a video capture application, you can’t guarantee the output quality nor guarantee that the animation is timely captured.
By using BeamToIX on top of D3.js, you can generate high-resolution frames at a selected frame rate.
To proof the concept, we will use an D3 datamap animated with BeamToIX, and use that animation to generate the frame sequence and an animated GIF.
Step-by-step tutorial
Just follow the following steps:
If BeamToIX isn’t install, read here how to install it.
Create BeamToIX project named
d3-datamap
:
beamtoix create d3-datamap --width 640 --height 360
HTML
- Add the d3, topojson and datamap script files to
index.html
beforebeamtoix.min.js
.
<script src="https://beamtoix.devtoix.com/files/by-date/2018/10/d3-datamap/lib/d3.min.js"></script>
<script src="https://beamtoix.devtoix.com/files/by-date/2018/10/d3-datamap/lib/topojson.min.js"></script>
<script src="https://beamtoix.devtoix.com/files/by-date/2018/10/d3-datamap/lib/datamaps.world.min.js"></script>
- Add the datamap container inside
scene1
:
(The story
is BeamToIX root element, and supports multiple scenes just like a theater play)
<div class="beamtoix-story" id=story>
<div class="beamtoix-scene" id=scene1>
<div id="map"></div>
<h1>GDP Per Capita</h1>
</div>
</div>
Stylesheet
- Add basic style to the
css/main.scss
file:
// the map will occupy the whole frame
#map {
position: absolute;
width: $beamtoix-width + px;
height: $beamtoix-height + px;
}
h1 {
z-index: 10;
letter-spacing: 0px;
position: relative;
margin: 9px;
text-decoration: underline;
font-size: 36px;
font-weight: bold;
color: #424242;
}
JavaScript
The code bellow is written in TypeScript, but you can use pure JavaScript.
- Load your data.
Since d3 datamap doesn’t supports country names, only ISO Country Codes, the first step is to load a list of Country Names and ISO Country Codes, and create JavaScript map of ISO alpha-3 per country.
In the second step, build a JavaScript array with a list of ISO alpha-3 Country codes and GDP per Capita, and call dataLoaded
with that data.
d3.json('https://beamtoix.devtoix.com/files/by-date/2018/10/d3-datamap/data-cors/iso-codes.json', (isoData) => {
// populate iso3 per country
const iso3perCountry = {};
isoData.forEach(item => {
iso3perCountry[item.country] = item.iso3;
});
d3.json('https://beamtoix.devtoix.com/files/by-date/2018/10/d3-datamap/data-cors/gdp-ppp.json', (gdpData) => {
// load GDP PER CAPITA per COUNTRY
const gdpPPPerCountry = gdpData.map(item => {
const iso3Code = iso3perCountry[item.country];
if (!iso3Code) {
throw `Unknown ISO alpha-3 for ${item.country}`;
}
return { iso3Code, gdpPPP: item.gdpPPP };
});
dataLoaded(gdpPPPerCountry);
});
});
- Place the animation and render command inside
dataLoaded
.
BeamToIX uses addAnimations
and addStills
to build the animation pipeline,
and story.render
to generate the file frames.
function dataLoaded(gdpPPPerCountry) {
const scene = story.scenes[0];
scene.addStills('2s');
story.render(story.bestPlaySpeed());
}
- Create a d3 datamap with bubbles as usual.
Disable all d3 animations since these animations aren’t controlled by BeamToIX. In this case, set animate: false
in the bubblesConfig
.
map = new Datamap({
element: document.getElementById('map'),
fills: {
bubbleColor: '#306596',
defaultFill: '#dddddd',
},
setProjection: (element) => {
const projection = d3.geo.mercator()
.scale(100)
.translate([element.offsetWidth / 2, element.offsetHeight / 2]);
const path = d3.geo.path().projection(projection);
return { path, projection };
},
bubblesConfig: {
// disable bubbles animation since, we will use only BeamToIX animations
animate: false,
borderWidth: 1,
},
});
JavaScript - Bubbles Animation
Since d3 isn’t a DOM element, we need to use BeamToIX VirtualAnimator
facility to animate a d3 datamap.
Our first goal is to animate the bubbles. In order to archive this process we derive from BeamToIX.SimpleVirtualAnimator
since it allow us to execute animateProps
once per frame, unlike BeamToIX.VirtualAnimator
which allows to call per property change.
- First, we prepare the data:
gdpPPPerCountry.sort((a, b) => a.gdpPPP > b.gdpPPP ? -1 : 1);
const maxGdpPPP = gdpPPPerCountry[0].gdpPPP;
const maxRadius = 20;
- Second, we create the
MapAnimator
with the methodanimateProps
:
class MapAnimator extends BeamToIX.SimpleVirtualAnimator {
animateProps(): void {
map.bubbles(
gdpPPPerCountry.map(item => {
return {
radius: parseFloat(this.props.t) * (item.gdpPPP / maxGdpPPP) * maxRadius,
centered: item.iso3Code,
fillKey: 'bubbleColor',
};
}));
}
}
- Third, we add the
MapAnimator
to the story:
The selector
will be used animate the properties.
const ca = new MapAnimator();
ca.selector = 'map-fx';
story.addVirtualAnimator(ca);
- Forth, we add an animation to the story:
scene.addAnimations([{
selector: '%map-fx', // must match ca.selector with `%` prefix
duration: `2s`,
props: [{
prop: 't',
valueStart: 0,
}],
}])
With these 4 steps, BeamToIX will execute MapAnimator.animateProps
once per frame, the number of frames is defined on BeamToIX.createStory
for 2 seconds, iterating the property t
from 0
(defined by valueStart
) to 1
(the end value is 1
by default).
JavaScript - Zoom Animation
The 2nd part of the animation is to zoom to Europe.d3.js
allows zooming by using attr
with scale
.
To add this animation with need the new iterator zoom
running from 1
to 5
.
Before the zoom animation starts, the zoom
value will be 0
.
- First, change the animation code to incorporate the
zoom
animation:
class MapAnimator extends BeamToIX.SimpleVirtualAnimator {
// this method will be called one for each frame
// this.prop.t goes from 0 to 1.
animateProps(): void {
const zoom = ca.props.z;
if (zoom < 1) {
// animate bubbles
map.bubbles(
gdpPPPerCountry.map(item => {
return {
radius: parseFloat(this.props.t) * (item.gdpPPP / maxGdpPPP) * maxRadius,
centered: item.iso3Code,
fillKey: 'bubbleColor',
};
}));
} else {
// animate zoom
const svg = map.svg;
svg.attr('transform', `translate(0, ${zoom * 100}), scale(${zoom})`);
svg.selectAll('circle').style('stroke-width', `${1 / zoom}px`);
svg.selectAll('path').style('stroke-width', `${1 / zoom}px`);
}
}
}
- Second, change the initialization of
VirtualAnimator
code to reset thezoom
property:
const ca = new MapAnimator();
ca.selector = 'map-fx';
ca.props.z = 0;
story.addVirtualAnimator(ca);
- Third, change scene animations to incorporate
zoom
property animation:
scene
.addAnimations([{
selector: '%map-fx',
duration: `2s`,
props: [{
prop: 't',
valueStart: 0,
}],
}])
// add still frames
.addStills('2s')
.addAnimations([{
selector: '%map-fx',
duration: `2s`,
props: [{
prop: 'z',
valueStart: 1,
value: 5,
}],
}])
// add still frames
.addStills('2s');
With these 3 extra steps, once the bubble animation finishes, it waits for 2s and then animates the zoom for another 2s.