<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>D3 Migration Bubble Chart</title>
<meta name="description" content="Example Bubble chart implementation in JS. Based on NYT visualization">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<style>
a, a:visited, a:active {
color: #444;
}
.container {
max-width: 1200px;
margin: auto;
}
.button {
min-width: 130px;
padding: 4px 5px;
cursor: pointer;
text-align: center;
font-size: 13px;
border: 1px solid #e0e0e0;
text-decoration: none;
}
.button.active {
background: #000;
color: #fff;
}
#vis {
width: 1300px;
height: 800px;
clear: both;
margin-bottom: 10px;
}
#toolbar {
margin-top: 10px;
}
.year {
font-size: 21px;
fill: #aaa;
cursor: default;
}
.tooltip {
position: absolute;
top: 100px;
left: 100px;
-moz-border-radius:5px;
border-radius: 5px;
border: 2px solid #000;
background: #fff;
opacity: .9;
color: black;
padding: 10px;
width: 300px;
font-size: 12px;
z-index: 10;
}
.tooltip .title {
font-size: 13px;
}
.tooltip .name {
font-weight:bold;
}
.footer {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<h1>Origin of Migrants into the United States</h1>
<div id="toolbar">
<a href="#" id="all" class="button active">All Available Years</a>
<a href="#" id="year" class="button">By Year</a>
</div>
<div id="vis"></div>
<div class="footer">
<p>Code base from Jim Vallandingham's demonstration of animated bubble charts in JavaScript and D3.js</p>
<p><a href="//vallandingham.me/bubble_charts_in_js.html">Blog Post</a> | <a href="https://github.com/vlandham/bubble_chart">Code</a></p>
<p>Dataset from <a href="//www.global-migration.info/">THE GLOBAL FLOW OF PEOPLE</a></p>
</div>
</div>
<script src="d3.js"></script>
<script>
/* bubbleChart creation function. Returns a function that will
* instantiate a new bubble chart given a DOM element to display
* it in and a dataset to visualize.
*
* Organization and style inspired by:
* https://bost.ocks.org/mike/chart/
*
*/
function bubbleChart() {
// Constants for sizing
var width = 1200;
var height = 800;
// tooltip for mouseover functionality
var tooltip = floatingTooltip('gates_tooltip', 240);
// Locations to move bubbles towards, depending
// on which view mode is selected.
var center = { x: width / 2, y: height / 2 };
var yearCenters = {
1990: { x: width / 3, y: height / 2 },
1995: { x: width / 2.2, y: height / 2 },
2000: { x: .62 * width, y: height / 2 },
2005: { x: .7 * width , y: height / 2 }
};
// X locations of the year titles.
var yearsTitleX = {
1990: 200,
1995: width * .38 ,
2000: width * 5/8,
2005: width - 200
};
// Used when setting up force and
// moving around nodes
var damper = 0.102;
// These will be set in create_nodes and create_vis
var svg = null;
var bubbles = null;
var nodes = [];
// Charge function that is called for each node.
// Charge is proportional to the diameter of the
// circle (which is stored in the radius attribute
// of the circle's associated data.
// This is done to allow for accurate collision
// detection with nodes of different sizes.
// Charge is negative because we want nodes to repel.
// Dividing by 8 scales down the charge to be
// appropriate for the visualization dimensions.
function charge(d) {
return -Math.pow(d.radius, 2.0) / 8;
}
// Here we create a force layout and
// configure it to use the charge function
// from above. This also sets some contants
// to specify how the force layout should behave.
// More configuration is done below.
var force = d3.layout.force()
.size([width, height])
.charge(charge)
.gravity(-0.01)
.friction(.9);
// Nice looking colors - no reason to buck the trend
var fillColor = d3.scale.ordinal()
.domain(['low', 'medium', 'high'])
.range(['#2A70AE', '#5486BB', '#99BBDD']);
// Sizes bubbles based on their area instead of raw radius
var radiusScale = d3.scale.pow()
.exponent(0.5)
.range([2, 85]);
/*
* This data manipulation function takes the raw data from
* the CSV file and converts it into an array of node objects.
* Each node will store data and visualization values to visualize
* a bubble.
*
* rawData is expected to be an array of data objects, read in from
* one of d3's loading functions like d3.csv.
*
* This function returns the new node array, with a node in that
* array for each element in the rawData input.
*/
function createNodes(rawData) {
// Use map() to convert raw data into node data.
// Checkout //learnjsdata.com/ for more on
// working with data.
var myNodes = rawData.map(function (d) {
return {
id: d.id,
continent: d.continent,
radius: radiusScale(+d.migration_number),
country_orig: d.country_orig,
migration_number: +d.migration_number,
year: d.year,
x: Math.random() * 900,
y: Math.random() * 800
};
});
// sort them to prevent occlusion of smaller nodes.
myNodes.sort(function (a, b) { return b.value - a.value; });
return myNodes;
}
/*
* Main entry point to the bubble chart. This function is returned
* by the parent closure. It prepares the rawData for visualization
* and adds an svg element to the provided selector and starts the
* visualization creation process.
*
* selector is expected to be a DOM element or CSS selector that
* points to the parent element of the bubble chart. Inside this
* element, the code will add the SVG continer for the visualization.
*
* rawData is expected to be an array of data objects as provided by
* a d3 loading function like d3.csv.
*/
var chart = function chart(selector, rawData) {
// Use the max total_amount in the data as the max in the scale's domain
// note we have to ensure the total_amount is a number by converting it
// with `+`.
var maxAmount = d3.max(rawData, function (d) { return +d.migration_number; });
radiusScale.domain([0, maxAmount]);
nodes = createNodes(rawData);
// Set the force's nodes to our newly created nodes array.
force.nodes(nodes);
// Create a SVG element inside the provided selector
// with desired size.
svg = d3.select(selector)
.append('svg')
.attr('width', width)
.attr('height', height);
// Bind nodes data to what will become DOM elements to represent them.
bubbles = svg.selectAll('.bubble')
.data(nodes, function (d) { return d.id; });
// Create new circle elements each with class `bubble`.
// There will be one circle.bubble for each object in the nodes array.
// Initially, their radius (r attribute) will be 0.
bubbles.enter().append('circle')
.classed('bubble', true)
.attr('r', 0)
.attr('fill', function (d) { return fillColor(d.migration_number); })
.attr('stroke', function (d) { return d3.rgb(fillColor(d.migration_number)).darker(); })
.attr('stroke-width', 2)
.on('mouseover', showDetail)
.on('mouseout', hideDetail);
// Fancy transition to make bubbles appear, ending with the
// correct radius
bubbles.transition()
.duration(2000)
.attr('r', function (d) { return d.radius; });
// Set initial layout to single group.
groupBubbles();
};
/*
* Sets visualization in "single group mode".
* The year labels are hidden and the force layout
* tick function is set to move all nodes to the
* center of the visualization.
*/
function groupBubbles() {
hideYears();
force.on('tick', function (e) {
bubbles.each(moveToCenter(e.alpha))
.attr('cx', function (d) { return d.x; })
.attr('cy', function (d) { return d.y; });
});
force.start();
}
/*
* Helper function for "single group mode".
* Returns a function that takes the data for a
* single node and adjusts the position values
* of that node to move it toward the center of
* the visualization.
*
* Positioning is adjusted by the force layout's
* alpha parameter which gets smaller and smaller as
* the force layout runs. This makes the impact of
* this moving get reduced as each node gets closer to
* its destination, and so allows other forces like the
* node's charge force to also impact final location.
*/
function moveToCenter(alpha) {
return function (d) {
d.x = d.x + (center.x - d.x) * damper * alpha;
d.y = d.y + (center.y - d.y) * damper * alpha;
};
}
/*
* Sets visualization in "split by year mode".
* The year labels are shown and the force layout
* tick function is set to move nodes to the
* yearCenter of their data's year.
*/
function splitBubbles() {
showYears();
force.on('tick', function (e) {
bubbles.each(moveToYears(e.alpha))
.attr('cx', function (d) { return d.x; })
.attr('cy', function (d) { return d.y; });
});
force.start();
}
/*
* Helper function for "split by year mode".
* Returns a function that takes the data for a
* single node and adjusts the position values
* of that node to move it the year center for that
* node.
*
* Positioning is adjusted by the force layout's
* alpha parameter which gets smaller and smaller as
* the force layout runs. This makes the impact of
* this moving get reduced as each node gets closer to
* its destination, and so allows other forces like the
* node's charge force to also impact final location.
*/
function moveToYears(alpha) {
return function (d) {
var target = yearCenters[d.year];
d.x = d.x + (target.x - d.x) * damper * alpha * 1.1;
d.y = d.y + (target.y - d.y) * damper * alpha * 1.1;
};
}
/*
* Hides Year title displays.
*/
function hideYears() {
svg.selectAll('.year').remove();
}
/*
* Shows Year title displays.
*/
function showYears() {
// Another way to do this would be to create
// the year texts once and then just hide them.
var yearsData = d3.keys(yearsTitleX);
var years = svg.selectAll('.year')
.data(yearsData);
years.enter().append('text')
.attr('class', 'year')
.attr('x', function (d) { return yearsTitleX[d]; })
.attr('y', 40)
.attr('text-anchor', 'middle')
.text(function (d) { return d; });
}
/*
* Function called on mouseover to display the
* details of a bubble in the tooltip.
*/
function showDetail(d) {
// change outline to indicate hover state.
d3.select(this).attr('stroke', 'black');
var content = '<span class="name">Country Origin: </span><span class="value">' +
d.country_orig +
'</span><br/>' +
'<span class="name">Number of Migrants: </span><span class="value">' +
addCommas(d.migration_number) +
'</span><br/>' +
'<span class="name">Year: </span><span class="value">' +
d.year +
'</span>';
tooltip.showTooltip(content, d3.event);
}
/*
* Hides tooltip
*/
function hideDetail(d) {
// reset outline
d3.select(this)
.attr('stroke', d3.rgb(fillColor(d.group)).darker());
tooltip.hideTooltip();
}
/*
* Externally accessible function (this is attached to the
* returned chart function). Allows the visualization to toggle
* between "single group" and "split by year" modes.
*
* displayName is expected to be a string and either 'year' or 'all'.
*/
chart.toggleDisplay = function (displayName) {
if (displayName === 'year') {
splitBubbles();
} else {
groupBubbles();
}
};
// return the chart function from closure.
return chart;
}
/*
* Below is the initialization code as well as some helper functions
* to create a new bubble chart instance, load the data, and display it.
*/
var myBubbleChart = bubbleChart();
/*
* Function called once data is loaded from CSV.
* Calls bubble chart function to display inside #vis div.
*/
function display(error, data) {
if (error) {
console.log(error);
}
myBubbleChart('#vis', data);
}
/*
* Sets up the layout buttons to allow for toggling between view modes.
*/
function setupButtons() {
d3.select('#toolbar')
.selectAll('.button')
.on('click', function () {
// Remove active class from all buttons
d3.selectAll('.button').classed('active', false);
// Find the button just clicked
var button = d3.select(this);
// Set it as the active button
button.classed('active', true);
// Get the id of the button
var buttonId = button.attr('id');
// Toggle the bubble chart based on
// the currently clicked button.
myBubbleChart.toggleDisplay(buttonId);
});
}
/*
* Helper function to convert a number into a string
* and add commas to it to improve presentation.
*/
function addCommas(nStr) {
nStr += '';
var x = nStr.split('.');
var x1 = x[0];
var x2 = x.length > 1 ? '.' + x[1] : '';
var rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + ',' + '$2');
}
return x1 + x2;
}
// Load the data.
d3.csv('migration_bubble.csv', display);
// setup the buttons.
setupButtons();
/*
* Creates tooltip with provided id that
* floats on top of visualization.
* Most styling is expected to come from CSS
* so check out bubble_chart.css for more details.
*/
function floatingTooltip(tooltipId, width) {
// Local variable to hold tooltip div for
// manipulation in other functions.
var tt = d3.select('body')
.append('div')
.attr('class', 'tooltip')
.attr('id', tooltipId)
.style('pointer-events', 'none');
// Set a width if it is provided.
if (width) {
tt.style('width', width);
}
// Initially it is hidden.
hideTooltip();
/*
* Display tooltip with provided content.
*
* content is expected to be HTML string.
*
* event is d3.event for positioning.
*/
function showTooltip(content, event) {
tt.style('opacity', 1.0)
.html(content);
updatePosition(event);
}
/*
* Hide the tooltip div.
*/
function hideTooltip() {
tt.style('opacity', 0.0);
}
/*
* Figure out where to place the tooltip
* based on d3 mouse event.
*/
function updatePosition(event) {
var xOffset = 20;
var yOffset = 10;
var ttw = tt.style('width');
var tth = tt.style('height');
var wscrY = window.scrollY;
var wscrX = window.scrollX;
var curX = (document.all) ? event.clientX + wscrX : event.pageX;
var curY = (document.all) ? event.clientY + wscrY : event.pageY;
var ttleft = ((curX - wscrX + xOffset * 2 + ttw) > window.innerWidth) ?
curX - ttw - xOffset * 2 : curX + xOffset;
if (ttleft < wscrX + xOffset) {
ttleft = wscrX + xOffset;
}
var tttop = ((curY - wscrY + yOffset * 2 + tth) > window.innerHeight) ?
curY - tth - yOffset * 2 : curY + yOffset;
if (tttop < wscrY + yOffset) {
tttop = curY + yOffset;
}
tt.style({ top: tttop + 'px', left: ttleft + 'px' });
}
return {
showTooltip: showTooltip,
hideTooltip: hideTooltip,
updatePosition: updatePosition
};
}
</script>
</body>
</html>