Block-a-Day #15. An alternative presentation of data from Mike Bostock's Population Pyramid block. Census observations of population cohorts, those born in the same five-year span, are shown as dots animated over time, with trailing lines showing the decline in population as the cohort ages (increases correspond to net immigration exceeding mortality for the cohort).
Space bar toggles animation.
Data Source: Minnesota Population Center via Mike Bostock.
What I Learned: The animation/slider logic was a good exercise in boiling down more complex code to the bare necessities (all in, it's about 50 lines). The most fiddly bit is at line 177, where we adjust the slider width to account for the extra space to the left and right of the bar.
What I'd Do With More Time: Highlight a cohort to emphasize it trajectory over time. Break out male and female population. Could be very cool to toggle between absolute and relative (i.e. mortality) values.
Just what it sounds like. For fifteen days, I will make a D3.js v4 block every single day. Rules:
forked from cmgiven's block: Cohort Trails
xxxxxxxxxx
<meta charset="utf-8">
<style>
body { font-family: sans-serif; }
#controls { height: 20px; padding: 15px; }
#chart { position: relative; height: 450px; }
#chart canvas, #chart canvas { position: absolute; top: 0; left: 0;}
input[type="range"]{
-webkit-appearance: none !important;
width: 100%;
height: 4px;
background: #aaa;
border-radius: 4px;
}
input[type="range"]::-webkit-slider-thumb{
-webkit-appearance: none !important;
width: 28px;
height: 28px;
background: #17b;
border-radius: 28px;
}
text { fill: #333; }
text.year {
font-family: monospace;
pointer-events: none;
}
.axis line { stroke: #ccc; }
.y.axis .domain { display: none; }
</style>
<body>
<div id="controls">
<input id="year-range" type="range" step="0.1" />
</div>
<div id="chart"></div>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
var margin = { top: 15, right: 15, bottom: 40, left: 52 }
var width = 960 - margin.left - margin.right
var height = 450 - margin.top - margin.bottom
var speed = 800
var handleWidth = 28
var radius = 5
var svg = d3.select('#chart').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 + ')')
var canvas = d3.select('#chart').append('canvas')
var ctx = canvas.node().getContext('2d')
if (window.devicePixelRatio) {
canvas
.attr('width', (width + margin.left + margin.right) * window.devicePixelRatio)
.attr('height', (height + margin.top + margin.bottom) * window.devicePixelRatio)
.style('width', (width + margin.left + margin.right) + 'px')
.style('height', (height + margin.top + margin.bottom) + 'px')
ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
} else {
canvas
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
}
ctx.translate(margin.left, margin.top)
var yearLabel = svg.append('text')
.attr('class', 'year')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('dy', '.26em')
.style('font-size', width / 3)
.style('text-anchor', 'middle')
.style('font-weight', 'bold')
.style('fill', '#ddd')
var xScale = d3.scaleLinear()
.domain([0, 90])
.range([0, width])
var yScale = d3.scaleLinear()
.domain([0, 24000000])
.range([height, 0])
var xAxis = d3.axisBottom()
.scale(xScale)
.ticks(20)
var yAxis = d3.axisLeft()
.scale(yScale)
.tickFormat(function (n) { return n / 1000000 })
.tickSize(-width)
.tickPadding(6)
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis)
.append('text')
.attr('x', width)
.attr('y', 30)
.style('text-anchor', 'end')
.style('font-weight', 'bold')
.text('Age at Census')
svg.append('g')
.attr('class', 'y axis')
.call(yAxis)
.append('text')
.attr('transform', 'rotate(-90)')
.attr('x', 0)
.attr('y', -26)
.style('text-anchor', 'end')
.style('font-weight', 'bold')
.text('Population (in millions)')
d3.csv('data.csv', function (d) {
return {
year: +d.year,
age: +d.age,
sex: +d.sex,
people: +d.people
}
}, initialize)
function initialize(data) {
var yearRange = d3.extent(data, function (d) { return d.year })
var year = yearRange[0]
var availableYears = d3.set(data.map(function (d) { return d.year })).values()
var yearRound = d3.scaleThreshold()
.domain(d3.pairs(availableYears).map(function (years) {
return (parseInt(years[0], 10) + parseInt(years[1], 10)) / 2
}))
.range(availableYears)
var key = 'total'
var animating = false
var dragging = false
var timer
var cohorts = d3.nest()
.key(function (d) { return d.year - d.age })
.sortKeys(d3.ascending)
.key(function (d) { return d.year })
.sortKeys(d3.ascending)
.rollup(function (d) {
var male = d.find(function (e) { return e.sex === 1 }).people
var female = d.find(function (e) { return e.sex === 2 }).people
return { age: d[0].age, male: male, female: female, total: male + female }
})
.entries(data)
var bisect = d3.bisector(function (d) { return +d.key }).right
window.focus()
d3.select(window).on('keydown', function () {
switch (d3.event.keyCode) {
case 32: toggleAnimation(); d3.event.preventDefault(); break // space
}
})
var slider = d3.select('#year-range')
.attr('min', yearRange[0])
.attr('max', yearRange[1])
.attr('value', year)
.on('mousedown', function () {
animating = false
dragging = true
// When the slider bar is clicked, we jump to that value. The handleWidth
// adjustment is necessary as the offsetWidth includes extra space to the
// left and right of the slider bar for when the handle is at either end.
year = yearRange[0] +
(yearRange[1] - yearRange[0]) *
(d3.event.offsetX - handleWidth / 2) /
(this.offsetWidth - handleWidth)
if (timer) { timer.stop() }
timer = d3.timer(update)
})
.on('mousemove', function () {
if (!dragging) { return }
year = parseFloat(this.value)
})
.on('mouseup', function () {
dragging = false
year = parseFloat(this.value)
snapYear()
})
update()
toggleAnimation()
function update() {
if (!dragging) { slider.node().value = year }
yearLabel.text(yearRound(year))
ctx.clearRect(-margin.left, -margin.top,
width + margin.left + margin.right,
height + margin.top + margin.bottom)
ctx.strokeStyle = '#17b'
ctx.fillStyle = '#17b'
var i = -1
var len = bisect(cohorts, year + 10)
while (++i < len) { drawCohort(cohorts[i]) }
}
function drawCohort(cohort) {
var i = 0
var len = bisect(cohort.values, year)
var valA = cohort.values[i]
var yearA = +valA.key
var x = xScale(valA.value.age)
var y = yScale(valA.value[key])
var rem = 1 - Math.min((year - yearA) / -10, 1)
var valB, yearB
while (++i < len + 1) {
valB = cohort.values[i]
yearB = valB ? +valB.key : yearA + 10
rem = yearB < year ? 1 : (year - yearA) / (yearB - yearA)
if (!valB || yearA === year) {
rem = Math.max(1 - rem, 0)
break
}
ctx.globalAlpha = 0.5 - (len - i) / 25
ctx.beginPath()
ctx.moveTo(x, y)
x += (xScale(valB.value.age) - x) * rem
y += (yScale(valB.value[key]) - y) * rem
ctx.lineTo(x, y)
ctx.stroke()
valA = valB
yearA = yearB
rem = 1
}
if (rem === 0) { return }
ctx.globalAlpha = rem
ctx.beginPath()
ctx.arc(x, y, radius, 0, 2 * Math.PI)
ctx.fill()
}
function toggleAnimation() {
if (dragging) { return }
if (animating) {
animating = false
snapYear()
} else {
animating = true
animateYear(year === yearRange[1] ? yearRange[0] : year, yearRange[1], speed)
}
}
function snapYear() {
animateYear(year, +yearRound(year), speed / 2)
}
function animateYear(start, end, speed) {
var distance = end - start
var duration = Math.abs(distance) / 10 * speed
if (timer) { timer.stop() }
timer = d3.timer(function (elapsed) {
if (elapsed >= duration) {
animating = false
timer.stop()
year = end
} else {
year = start + elapsed / duration * distance
}
update()
})
}
}
</script>
</body>
https://d3js.org/d3.v4.min.js