A stacked area chart showing Syrian refugees, by settlement type.
Data from the UN Refugee Agency Data API.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<script src="https://d3js.org/d3.v4.min.js"></script>
<title>Syria Situation Prototypes</title>
<style>
.axis .tick text, .legend text, .tooltip text {
fill: #585858;
font-family: sans-serif;
font-size: 12pt;
}
.axis .tick line{
stroke: #ddd;
}
.axis .domain {
display: none;
}
</style>
</head>
<body>
<svg width="960" height="300"></svg>
<script>
const svg = d3.select('svg');
const width = svg.attr('width');
const height = svg.attr('height');
const margin = { top: 28, bottom: 20.5, left: 10, right: 54.5 };
//const inCampEndpoint = 'https://data2.unhcr.org/api/population/get/timeseries?widget_id=41115&sv_id=4&population_collection=25&frequency=day&fromDate=2011-01-01';
//const urbanRuralEndpoint = 'https://data2.unhcr.org/api/population/get/timeseries?widget_id=41116&sv_id=4&population_collection=26&frequency=day&fromDate=2011-01-01';
const inCampEndpoint = 'inCamp.json';
const urbanRuralEndpoint = 'urbanRural.json';
const dateFromTimestamp = unix_timestamp => new Date(unix_timestamp * 1000);
const bisectDate = d3.bisector(d => d.date).left;
function getInterpolatedValue (timeseries, date, value){
const i = bisectDate(timeseries, date, 0, timeseries.length - 1);
if (i > 0) {
const a = timeseries[i - 1];
const b = timeseries[i];
const t = (date - a.date) / (b.date - a.date);
return value(a) * (1 - t) + value(b) * t;
}
return value(timeseries[i]);
}
d3.queue()
.defer(d3.json, inCampEndpoint)
.defer(d3.json, urbanRuralEndpoint)
.awaitAll((error, results) => {
if (error) throw error;
// Parse dates from timestamps for all timeseries.
results.forEach(result => {
result.data.timeseries.forEach(d => {
d.date = dateFromTimestamp(d.unix_timestamp);
});
});
// Because each timeseries has different date intervals,
// we impute values using linear interpolation.
// First we need the union of all dates from both timeseries.
const dates = d3
.set(
results.reduce((a, b) => a.data.timeseries.concat(b.data.timeseries)),
d => d.unix_timestamp
)
.values() // Returns strings
.map(d => +d) // Parse into numbers for sorting
.sort()
.map(dateFromTimestamp);
// Transform data into a structure usable by d3.stack.
const keys = results.map(result => result.title_language_en);
const conciseKeys = keys.map(key => key.replace(' by date', ''));
const data = dates.map(date => {
// Create a new row object with the date.
const d = { date: date };
// Assign values to the new row object for each key.
// Value for `key` here will be e.g. "In-camp by date" or "Urban/rural by date".
keys.forEach((key, j) => {
const timeseries = results[j].data.timeseries;
d[conciseKeys[j]] = getInterpolatedValue(timeseries, date, d => d.individuals);
});
return d;
});
render(data, conciseKeys, results);
});
const innerWidth = width - margin.right - margin.left;
const innerHeight = height - margin.top - margin.bottom;
const xScale = d3.scaleTime().range([0, innerWidth]);
const yScale = d3.scaleLinear().range([innerHeight, 0]);
// Colors and opacity derived from //data2.unhcr.org
const colorScale = d3.scaleOrdinal()
.range(['rgb(60, 141, 188)', 'rgb(48, 48, 48)']);
const areaFillOpacity = 0.2;
const xAxis = d3.axisBottom()
.scale(xScale);
const yAxis = d3.axisRight()
.tickSize(-innerWidth)
.tickPadding(5)
.scale(yScale)
.ticks(5)
.tickFormat(d3.format('.2s'));
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const xAxisG = g.append('g')
.attr('class', 'axis')
.attr('transform', `translate(0,${innerHeight})`);
const yAxisG = g.append('g')
.attr('class', 'axis')
.attr('transform', `translate(${innerWidth},0)`);
// This layer will contain the marks (stacked area layers).
const marksG = g.append('g');
// This layer will contain the tooltip.
const tooltipG = g.append('g')
.attr('class', 'tooltip');
// This rectangle intercepts mouse events to drive the tooltip.
const eventRect = g.append('rect')
.attr('fill', 'none')
.attr('pointer-events', 'all');
const colorLegendG = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${margin.left},13)`);
const area = d3.area()
.x(d => xScale(d.data.date))
.y0(d => yScale(d[0]))
.y1(d => yScale(d[1]));
const stack = d3.stack();
function render(data, keys, results) {
const stackedData = stack.keys(keys)(data);
xScale.domain(d3.extent(data, d => d.date));
yScale
.domain([0, d3.max(stackedData[stackedData.length - 1], d => d[1])]);
colorScale.domain(keys);
const paths = marksG.selectAll('path').data(stackedData);
paths
.enter().append('path')
.attr('fill-opacity', areaFillOpacity)
.merge(paths)
.attr('d', area)
.attr('fill', d => colorScale(d.key))
.attr('stroke', d => colorScale(d.key));
xAxisG.call(xAxis);
yAxisG.call(yAxis);
renderColorLegend();
const tooltipDatum = () => {
const coords = d3.mouse(eventRect.node());
const date = xScale.invert(coords[0]);
const index = bisectDate(data, date);
return data[Math.min(index, data.length - 1)];
}
eventRect
.attr('width', innerWidth)
.attr('height', innerHeight)
.on('mousemove', () => {
renderTooltip([tooltipDatum()], keys);
})
.on('mouseout', () => {
renderTooltip([]);
});
}
function renderColorLegend() {
const entrySpacing = 130;
const entryRectWidth = 50;
const entryRectHeight = 20;
const entryTextPadding = 4;
const entries = colorLegendG.selectAll('g').data(colorScale.domain());
entries
.enter().append('text')
.attr('alignment-baseline', 'middle')
.attr('x', (d, i) => i * entrySpacing + entryRectWidth + entryTextPadding)
.attr('y', 0)
.text(d => d);
entries
.enter().append('rect')
.attr('x', (d, i) => Math.round(i * entrySpacing) - .5) // .5 for crisp edges.
.attr('y', Math.round(-entryRectHeight / 2) - .5)
.attr('width', entryRectWidth)
.attr('height', entryRectHeight)
.attr('fill-opacity', areaFillOpacity)
.attr('fill', colorScale)
.attr('stroke', colorScale);
}
const dateFormat = d3.timeFormat('%B %d, %Y');
const commaFormat = d3.format(',');
const numberFormat = n => commaFormat(Math.round(n));
const lines = keys => d => {
const total = `Total: ${numberFormat(d3.sum(keys.map(key => d[key])))}`;
const keyValues = keys.map(key => `${key}: ${numberFormat(d[key])}`);
return [dateFormat(d.date)].concat(total).concat(keyValues);
}
const lineHeight = 20;
const tooltipRectWidth = 180;
const tooltipRectHeight = 90;
function renderTooltip(hoveredData, keys) {
// Show a line representing the hovered date.
const line = tooltipG.selectAll('line').data(hoveredData);
line.exit().remove();
line
.enter().append('line')
.merge(line)
.attr('x1', d => xScale(d.date))
.attr('y1', 0)
.attr('x2', d => xScale(d.date))
.attr('y2', innerHeight)
.attr('stroke', 'black')
.attr('stroke-opacity', 0.5);
// Create a group element to contain the tooltip.
let g = tooltipG.selectAll('g').data(hoveredData);
g.exit().remove();
g = g
.enter().append('g')
.merge(g)
.attr('transform', d => {
let x = xScale(d.date);
if ( (x + tooltipRectWidth) > innerWidth) {
x -= tooltipRectWidth;
}
const y = lineHeight;
return `translate(${x},${y})`
});
// The background rectangle.
const rect = g.selectAll('rect').data([1]);
rect
.enter().append('rect')
.attr('y', -20)
.attr('width', tooltipRectWidth)
.attr('height', tooltipRectHeight)
.attr('fill', 'white')
.attr('stroke', 'gray')
.attr('rx', 10);
// The text (multiple lines).
const text = g.selectAll('text').data(lines(keys));
text
.enter().append('text')
.merge(text)
.attr('x', 10)
.attr('y', (d, i) => i * lineHeight)
.text(d => d);
}
</script>
</body>
</html>