cmgiven’s Block e5dfe0888968ee8c507f5469a4d62b6f
Updated October 27, 2019

Gooey Exploding Scatterplot

Open

Block-a-Day #10. In this Gapminder recreation, continents can be exploded into their constituent countries with a paintball-splatter-like effect.

Data Sources: Gapminder.

What I Learned: I've wanted to build something with this technique ever since I saw Nadieh Bremer's demonstrations of it. My addition is to create a separate filter definition for each continent, enabling them to be individually transitioned. For more details, check out her original blog post and follow-up.

What I'd Do With More Time: The explosions have to wait until the next year tick. I'd like to be able to explode them immediately, but I'm not aware of a way to do this with d3.transition alone, so it would probably require a custom animation loop.

Block-a-Day

Just what it sounds like. For fifteen days, I will make a D3.js v4 block every single day. Rules:

  1. Ideas over implementation. Do something novel, don't sweat the details.
  2. No more than two hours can be spent on coding (give or take).
  3. Every. Single. Day.

Previously

index.html#

<!DOCTYPE html>
<meta charset="utf-8">
<style>
text { fill: #333; }
text.year {
    font-family: monospace;
    pointer-events: none;
}
#controls {
    position: absolute;
    top: 15px; right: 15px;
}
</style>
<body>
<div id="controls">
    <button id="explode">Explode All</button>
    <button id="collapse">Collapse All</button>
</div>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
var years = d3.range(1950, 2015 + 1)
var interval = 500

var blurStable = '1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 5 -7'
var blurIn = '1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 35 -11'
var blurOut = '1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 15 -7'

var margin = { top: 15, right: 15, bottom: 45, left: 55 }
var width = 960 - margin.left - margin.right
var height = 500 - margin.top - margin.bottom

var svg = d3.select('body')
    .append('svg')
    .attr('width', width + margin.left + margin.top)
    .attr('height', height + margin.top + margin.bottom)
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')

var x = d3.scaleLinear().range([0, width])
var y = d3.scaleLinear().range([height, 0])
var r = d3.scaleSqrt().range([0, 50])
var color = d3.scaleOrdinal()
    .range(['#1b9e77', '#d95f02', '#7570b3', '#e7298a', '#e6ab02', '#666666'])

var xAxis = d3.axisBottom().scale(x)
var yAxis = d3.axisLeft().scale(y)

var yearLabel = svg.append('text')
    .attr('class', 'year')
    .attr('x', width / 2)
    .attr('y', height / 2)
    .attr('dy', '.28em')
    .style('font-size', width / 3)
    .style('text-anchor', 'middle')
    .style('font-weight', 'bold')
    .style('opacity', 0.2)
    .text('1950')

d3.csv('data.csv', function (d) {
    return {
        country: d.country,
        year: d.year,
        life_expectancy: +d.life_expectancy,
        total_fertility: +d.total_fertility,
        population: +d.population,
        continent: d.continent
    }
}, initialize)

function initialize(error, data) {
    if (error) { throw error }

    x.domain([0, d3.max(data, function (d) { return d.total_fertility })]).nice()
    y.domain([0, d3.max(data, function (d) { return d.life_expectancy })]).nice()
    r.domain([0, d3.max(data, function (d) { return d.population })])

    data = d3.nest()
        .key(function (d) { return d.year })
        .key(function (d) { return d.continent })
        .entries(data)

    data.forEach(function (d) {
        d.values.forEach(function (e) {
            e.population = d3.sum(e.values, function (f) { return f.population })
            e.total_fertility = d3.sum(e.values, function (f) {
                return f.population * f.total_fertility
            }) / e.population
            e.life_expectancy = d3.sum(e.values, function (f) {
                return f.population * f.life_expectancy
            }) / e.population
            e.values.forEach(function (f) { f.parent = e })
        })
    })

    var uniqueContinents = data[0].values.map(function (d) { return d.key })

    // from http://www.visualcinnamon.com/2016/06/fun-data-visualizations-svg-gooey-effect.html
    // modified to create a filter for each continent group, which can be individually transitioned
    var filters = svg.append('defs')
        .selectAll('filter')
        .data(uniqueContinents)
        .enter().append('filter')
        .attr('id', function (d) { return 'gooeyCodeFilter-' + d.replace(' ', '-') })

    filters.append('feGaussianBlur')
        .attr('in', 'SourceGraphic')
        .attr('stdDeviation', '10')
        .attr('color-interpolation-filters', 'sRGB')
        .attr('result', 'blur')

    var blurValues = filters.append('feColorMatrix')
        .attr('class', 'blurValues')
        .attr('in', 'blur')
        .attr('mode', 'matrix')
        .attr('values', blurStable)
        .attr('result', 'gooey')

    filters.append('feBlend')
        .attr('in', 'SourceGraphic')
        .attr('in2', 'gooey')

    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('Fertility (births per woman)')

    svg.append('g')
        .attr('class', 'y axis')
        .call(yAxis)
        .append('text')
        .attr('transform', 'rotate(-90)')
        .attr('x', 0)
        .attr('y', -30)
        .style('text-anchor', 'end')
        .style('font-weight', 'bold')
        .text('Life expectancy (years)')

    var yearIndex = 0
    var year = '' + years[yearIndex]
    var exploded = d3.set()
    var blurTransition = d3.set()

    d3.select('#explode').on('click', function () {
        uniqueContinents.forEach(function (d) {
            if (!exploded.has(d)) {
                exploded.add(d)
                blurTransition.add(d)
            }
        })
    })

    d3.select('#collapse').on('click', function () {
        uniqueContinents.forEach(function (d) {
            if (exploded.has(d)) {
                exploded.remove(d)
                blurTransition.add(d)
            }
        })
    })

    var continents = svg.selectAll('.continent')
        .data(data[0].values)
        .enter().append('g')
        .attr('class', 'continent')
        .style('filter', function (d) { return 'url(#gooeyCodeFilter-' + d.key.replace(' ', '-') + ')' })

    continents.append('circle')
        .attr('class', 'aggregate')
        .attr('cx', width / 2)
        .attr('cy', height / 2)
        .style('fill', function (d) { return color(d.key) })
        .on('click', function (d) { exploded.add(d.key); blurTransition.add(d.continent) })
        .append('title').text(function (d) { return d.key })

    update()
    d3.interval(incrementYear, interval)

    function incrementYear() {
        year = '' + years[++yearIndex >= years.length ? yearIndex = 0 : yearIndex]
        update()
    }

    function update() {
        yearLabel.transition().duration(0).delay(interval / 2).text(year)
        continents = continents.data(
            data.find(function (d) { return d.key === year }).values,
            function (d) { return d.key }
        )

        var countries = continents.selectAll('.country')
            .data(function (d) { return d.values }, function (d) { return d.country })

        countries.exit().remove()

        var enterCountries = countries.enter().insert('circle', '.aggregate')
            .attr('class', 'country')
            .attr('cx', width / 2)
            .attr('cy', height / 2)
            .style('fill', function (d) { return color(d.continent) })
            .on('click', function (d) { exploded.remove(d.continent); blurTransition.add(d.continent) })

        enterCountries.append('title').text(function (d) { return d.country })

        countries = countries.merge(enterCountries)

        var t = d3.transition()
            .ease(d3.easeLinear)
            .duration(interval)

        continents.select('.aggregate')
            .transition(t)
            .attr('r', function (d) { return exploded.has(d.key) ? 0 : r(d.population) })
            .attr('cx', function (d) { return x(d.total_fertility) })
            .attr('cy', function (d) { return y(d.life_expectancy) })

        countries
            .transition(t)
            .attr('r', function (d) { return r(d.population) })
            .attr('cx', function (d) { return x((exploded.has(d.continent) ? d : d.parent).total_fertility) })
            .attr('cy', function (d) { return y((exploded.has(d.continent) ? d : d.parent).life_expectancy) })

        blurValues
            .transition(t)
            .attrTween('values', function (d) {
                if (blurTransition.has(d)) {
                    blurTransition.remove(d)

                    if (exploded.has(d)) {
                        return d3.interpolateString(blurIn, blurOut)
                    } else {
                        return d3.interpolateString(blurOut, blurIn)
                    }
                }

                return function () { return blurStable }
            })
    }
}

</script>
</body>

data.csv#

File too large to display (584 KB). View raw.

LICENSE#

Released under The MIT License.