Show age trends through time by animating a timeline, changing opacities, colors and widths proportional to the year within the temporal series. Timeline contains data a regional level, mouseover show national data for the same year and percentage differences between the two series
d3.js recreation of this example of (animated timelines from the flowingData blog)[http://flowingdata.com/2017/07/17/marrying-age-over-the-past-century/], made in R:
xxxxxxxxxx
<style>
#tooltip {
pointer-events: none;
position: absolute;
left: 0;
top: 0;
color: rgb(68, 68, 68);
font-family: sans-serif;
font-weight: 200;
font-size: 11px;
box-shadow: rgba(0, 0, 0, 0.247059) 0px 1px 3px;
z-index: 2000;
background-color: rgb(255, 255, 255);
opacity: 0.95;
padding: 6px;
}
#tooltip .bar {
stroke-width: 1px;
stroke: white;
}
#tooltip .bar--positive {
fill: #EF5553;
}
#tooltip .bar--negative {
fill: #1F4373;
}
#tooltip text {
font: 8px sans-serif;
color: #333333;
}
#tooltip path,
#tooltip line {
fill: none;
shape-rendering: crispEdges;
stroke: #333;
stroke-dasharray: 2,2;
}
#tooltip .tooltip-title {
font-weight: bold;
font-size: 14px;
}
</style>
<div id="tooltip"></div>
<div id="placeholder" style="width:960px;">
<svg id="canvas" width="1100" height="400"></svg>
</div>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="lodash.min.js"></script>
<script>
var svg = d3.select('svg'),
placeholder_width = 1100,
margin = {top: 20, right: 35, bottom: 30, left: 35},
width = +svg.attr("width") - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom,
g = svg.append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var x = d3.scalePoint();
var y = d3.scaleLinear()
.rangeRound([height, 0]);
var line = d3.line()
.curve(d3.curveBasis)
.x(function(d) {
return (x(d.age) == undefined)? 0: x(d.age);
})
.y(function(d) {
return (y(d.total) == undefined)? 0: y(d.total);
});
var round5Ceil = function(x) { return Math.ceil(x / 5) * 5; },
round5Floor = function(x) { return Math.floor(x / 5) * 5; },
minYear,
maxYear,
toscanaData,
italiaData,
allData,
globalBins,
scaleBand,
rankNames = ['Professore ordinario', 'Ricercatore a tempo indeterminato', 'Professore associato'],
regionalDataYearly = {},
nationalDataYearly = {};
var nestByRankName = function(data, geoScope) {
var nest = d3.nest()
.key(function(d) { return d.rankName})
.entries(_.cloneDeep(data));
nest = _.map(nest, function(obj){
var totalSumYdimension = d3.sum(_.map(obj.values, 'total'));
return {key:obj.key, values: _.map(globalBins, function(bin) {
var binMin = bin.x0,
binMax = bin.x1,
binSet = _.filter(obj.values, function(d){ return (binMin <= d.age) && (d.age <= binMax); }),
rObj = {};
rObj.age = Math.round(binMin) + '-' + Math.round(binMax);
rObj.total = Number(((d3.sum(_.map(binSet, 'total'))/totalSumYdimension)*100).toFixed(2));
rObj['region'] = geoScope;
return Object.assign({}, _.omit(obj.values[0], ['age', 'total']), rObj);
}) }
});
return nest;
}
// prepare yearly data for the histogram showing
// percentages for 5-year spans
var prepareDataByYear = function(toscanaRawData, italiaRawData, year) {
toscanaData = _.sortBy(
_.filter(toscanaRawData, {'year' : year}),
'age'
);
italiaData = _.sortBy(
_.filter(italiaRawData, {'year' : year}),
'total'
);
allData = toscanaData.concat(italiaData);
// in order to perform the age histogram, we could go for the option
// of having yearly binning, but not all the years are available. So
// in order to have 'pretty bins', look for the boundaries of the age
// property and extend by looking to its nearest multiple of 5, so we
// can have uniform bins of 5-year spans
var bottomBinValue = round5Floor( d3.min(_.map(allData, 'age')) ),
topBinValue = round5Ceil( d3.max(_.map(allData, 'age')) ),
numerOfBins = (topBinValue - bottomBinValue) / 5;
globalBins = d3.histogram()
.domain([bottomBinValue, topBinValue])
.thresholds(numerOfBins)(_.map(allData, 'age'));
toscanaMultiData = nestByRankName(toscanaData, 'toscana');
italiaMultiData = nestByRankName(italiaData, 'italia');
regionalDataYearly[year] = _.flatten(_.map(toscanaMultiData, 'values'));
nationalDataYearly[year] = _.flatten(_.map(italiaMultiData, 'values'));
};
var drawTooltip = function(regionalDataYear, rankName, year) {
var nationalDataYear = _(nationalDataYearly)
.values()
.flatten()
.filter({'rankName':rankName, 'year': year})
.value();
// get keys present in both datasets
// and compute differences
var diffs = _(_.intersection(
_.map(nationalDataYear, 'age'),
_.map(regionalDataYear, 'age')
)
)
.map(
function(ageKey) {
return {
age : ageKey,
diff : +(_.find(nationalDataYear, {'age' : ageKey}).total - _.find(regionalDataYear, {'age' : ageKey}).total).toFixed(2)
};
}
)
.value();
var margin = {top: 50, right: 0, bottom: 10, left: 0},
width = 250 - margin.left - margin.right,
height = 80 - margin.top - margin.bottom;
x_spark = d3.scaleBand().domain(_.map(diffs, 'age')).range([0,width]),
y_spark = d3.scaleLinear().domain(d3.extent(_.map(diffs, 'diff'))).range([0, height]);
d3.select('#tooltip').selectAll('svg').remove();
d3.select('#tooltip')
.style('display', 'block')
.style('left', (d3.event.pageX + 20) + 'px')
.style('top', (d3.event.pageY - 100) + 'px');
var svg = d3.select('#tooltip').append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g').attr('transform','translate(' + margin.left + ', ' + margin.top + ')');
svg.selectAll('.bar')
.data(diffs)
.enter()
.append('rect')
.attr("class", function(d) { return "bar bar--" + (d.diff < 0 ? "negative" : "positive"); })
.attr("x", function(d) { return x_spark(d.age); })
.attr("y", function(d) { return y_spark(Math.min(0, d.diff)) })
.attr("height", function(d) {
var h = Math.abs(y_spark(d.diff) - y_spark(0));
return h == 0 ? 1 : h;
})
.attr("width", x_spark.bandwidth());
svg.selectAll('text')
.data(diffs)
.enter()
.append('text')
.text(function(d) { return d.diff == 0 ? '' : d.diff.toFixed(1); })
.attr('text-anchor', 'middle')
.attr('alignment-baseline', function(d) { return d.diff < 0 ? 'baseline' : 'hanging' ;})
.attr("x", function(d) { return x_spark(d.age) + (x_spark.bandwidth()/2); })
.attr("y", function(d) { return y_spark(d.diff) + (d.diff < 0 ? -2 : 2) });
svg.append('text').attr('class', 'tooltip-title').text(year).attr('y', (-margin.top + 10))
svg.append('text').attr('class', 'tooltip-subtitle').text('Percentage differences').attr('y', (-margin.top/2))
svg.append("line")
.attr("x1", 0)
.attr("x2", width)
.attr("y1", function(d) { return y_spark(0); })
.attr("y2", function(d) { return y_spark(0); })
}
var drawNationalLineByYear = function(placeholder, rankName, year) {
placeholder.select('.nationalLine').remove();
d3.select('#tooltip').style('display', 'block');
placeholder.append("path")
.attr('class', 'nationalLine')
.style('pointer-events', 'none')
.datum(
_(nationalDataYearly)
.values()
.flatten()
.filter({'rankName':rankName, 'year': year})
.value()
)
.attr("fill", "none")
.attr("stroke", "#FF530D")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 6)
.attr("d", line)
.style("opacity", .7);
//.lower();
}
var createSmallMultiple = function(g, rankData) {
g.append("g")
.attr("transform", "translate(0," + height + ")")
.call(d3.axisBottom(x))
.append('text')
.attr('class', 'axis-label')
.attr("fill", "#000")
.attr("x", (x.range()[1] - 30))
.attr("y", -10)
.attr('font-weight', 'bold')
.text('età (anni)');
g.append("g")
.call(d3.axisLeft(y))
.append("text")
.attr('class', 'axis-label')
.attr("fill", "#000")
.attr("transform", "rotate(-90)")
.attr("y", 6)
.attr("dy", "0.71em")
.attr('font-weight', 'bold')
.attr("text-anchor", "end")
.text("percentuale (%)");
var colorRange = d3.scaleLinear()
.domain([0, maxYear-minYear])
.range([0,1]);
_.uniq(_.map(rankData, 'year')).forEach( function(year, i) {
setTimeout(function() {
g.append("path")
.attr('class', 'regionalLine')
.datum(_.filter(rankData, {'year' : year}))
.attr("fill", "none")
.attr("stroke", d3.interpolateRdPu(1))
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", (1 + colorRange(i) ))
.attr("d", line)
.style("opacity", 1)
.on('mouseover', function(d) {
d3.selectAll('.regionalLine').transition().style('opacity',0.1);
d3.select(this)
.attr('stroke-width', 3)
.transition().style('opacity', 1);
drawNationalLineByYear(
g,
_.first(d).rankName,
_.first(d).year)
drawTooltip(d, _.first(d).rankName, _.first(d).year);
})
.on('mouseout', function() {
d3.select('#tooltip').selectAll('svg').remove();
d3.select('#tooltip').style('display', 'none');
d3.select(this).attr('stroke-width', (1 + colorRange(i)));
d3.select('.nationalLine').remove();
d3.selectAll('.regionalLine').transition().style('opacity', function() {
return d3.select(this).attr('__opacity__');
});
})
.transition()
.duration(1000)
.style("opacity", (i+1) * (1/(maxYear-minYear)))
.attr('__opacity__', (i+1) * (1/(maxYear-minYear)))
.attr("stroke", d3.interpolateRdPu(colorRange(i)));
},
150 * (colorRange(i)*10))
});
};
var mapAges = function(d) {
return _(d)
.values()
.flatten()
.map('age')
.value();
};
d3.json('data.json', function(data) {
// temporal data, look for min,max years
maxYear = _.min(
[ _.max(_.map(data[0], 'year')),
_.max(_.map(data[1], 'year'))
]);
minYear = _.max(
[ _.min(_.map(data[0], 'year')),
_.min(_.map(data[1], 'year'))
]);
// calculate histogram per each year
_.range(minYear, maxYear+1).forEach(function(y) {
prepareDataByYear(data[0], data[1], y);
});
x.domain(
_.uniq(
mapAges(regionalDataYearly)
)
);
scaleBand = d3.scaleBand()
.domain(rankNames)
.range([0, placeholder_width]);
y.domain([0, 50]);
x.range([0, scaleBand.bandwidth() - margin.right]);
rankNames.forEach(function(rankName) {
createSmallMultiple(
svg
.append('g')
.attr('class', rankName)
.attr('transform', 'translate(' + scaleBand(rankName) + ', 0)'),
_(regionalDataYearly)
.values()
.flatten()
.filter({'rankName':rankName})
.value()
);
})
});
</script>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-scale-chromatic.v1.min.js