block by timelyportfolio 86b67adad02ea8ab8845abeedaa269cb

Zoomable Treemap Bar Chart

Full Screen

Block-a-Day #12. Update of Treemap Bar Chart to enable zooming into a specific country on click.

Data Sources: Census

What I Learned: In a bit of treemap implementation trivia, nodes without a parent value of zero (i.e. those in a different continent than the selected country) will have NaN for their position values, as a result of a divide by zero, while those with a parent value but no value of their own will have position values, although width and height of, obviously, zero. I was originally just catching the NaNs, but this resulted in nodes flying to two different positions, thus the checking of d.value to determine how to position the nodes.

What I’d Do With More Time: Might be interesting if the transition was to vertically expand and fade the non-selected countries, to reinforce the idea of zooming in.

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>
label {
    font-family: sans-serif;
    font-size: 14px;
    position: absolute;
    left: 92px; top: 26px;
}
.axis .domain { display: none; }
.axis line { stroke: #ccc; }
.axis.x0 text { font-weight: 700; }
.hover-active rect { opacity: .75; }
.hover-active rect.hover { opacity: 1; }
</style>
<body>
<label><input type="checkbox" id="inflation-adjusted" checked /> Adjust for inflation</label>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
var margin = { top: 15, right: 15, bottom: 40, left: 60 }
var width = 960 - margin.left - margin.right
var height = 500 - margin.top - margin.bottom

var orderedContinents = ['Asia', 'North America', 'Europe', 'South America', 'Africa', 'Australia']
var color = d3.scaleOrdinal()
    .domain(orderedContinents)
    .range(['#66c2a5', '#fc8d62', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f'])

var dollarFormat = d3.format('$,')
var tickFormat = function (n) {
    return n === 0 ? '$0'
        : n < 1000000 ? dollarFormat(n / 1000) + ' billion'
            : dollarFormat(n / 1000000) + ' trillion'
}

var options = {
    key: 'adj_value',
    country: null
}

d3.json('data.json', initialize)

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

    var root = d3.hierarchy(data).sum(function (d) { return d[options.key] })
    var yearData = root.children

    yearData.sort(function (a, b) { return a.data.year - b.data.year })

    var svg = d3.select('body')
        .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 x0 = d3.scaleBand()
        .domain(yearData.map(function (d) { return d.data.year }).sort())
        .range([0, width])
        .padding(0.15)

    var x1 = d3.scaleBand()
        .domain(['Imports', 'Exports'])
        .rangeRound([0, x0.bandwidth()])
        .paddingInner(0.1)

    var y = d3.scaleLinear()
        .domain([0, d3.max(yearData, function (d) {
            return d3.max(d.children, function (e) { return e.value })
        })]).nice()
        .range([0, height])

    var x0Axis = d3.axisBottom()
        .scale(x0)
        .tickSize(0)

    var x1Axis = d3.axisBottom()
        .scale(x1)

    var yAxis = d3.axisLeft()
        .tickSize(-width)
        .tickFormat(tickFormat)
        .scale(y.copy().range([height, 0]))

    svg.append('g')
        .attr('class', 'x0 axis')
        .attr('transform', 'translate(0,' + (height + 22) + ')')
        .call(x0Axis)

    var gy = svg.append('g')
        .attr('class', 'y axis')
        .call(yAxis)

    var years = svg.selectAll('.year')
        .data(yearData, function (d) { return d.data.year })
        .enter().append('g')
        .attr('class', 'year')
        .attr('transform', function (d) {
            return 'translate(' + x0(d.data.year) + ',0)'
        })

    years.append('g')
        .attr('class', 'x1 axis')
        .attr('transform', 'translate(0,' + height + ')')
        .call(x1Axis)

    d3.select('#inflation-adjusted').on('change', function () {
        options.key = this.checked ? 'adj_value' : 'value'
        update()
    })

    update()

    function sum(d) {
        return !options.country || options.country === d.country ? d[options.key] : 0
    }

    function update() {
        root.sum(sum)

        var t = d3.transition()

        var typeData = d3.merge(yearData.map(function (d) { return d.children }))

        y.domain([0, d3.max(typeData.map(function (d) { return d.value }))]).nice()

        // We use a copied Y scale to invert the range for display purposes
        yAxis.scale(y.copy().range([height, 0]))
        gy.transition(t).call(yAxis)

        var types = years.selectAll('.type')
            .data(function (d) { return d.children },
                  function (d) { return d.data.type })
            .each(function (d) {
                // UPDATE
                // The copied branches are orphaned from the larger hierarchy, and must be
                // updated separately (see note at L152).
                d.treemapRoot.sum(sum)
                d.treemapRoot.children.forEach(function (d) {
                    d.sort(function (a, b) { return b.value - a.value })
                })
            })

        types = types.enter().append('g')
            .attr('class', 'type')
            .attr('transform', function (d) {
                return 'translate(' + x1(d.data.type) + ',' + height + ')'
            })
            .each(function (d) {
                // ENTER
                // Note that we can use .each on selections as a way to perform operations
                // at a given depth of the hierarchy tree.
                d.children.sort(function (a, b) {
                    return orderedContinents.indexOf(b.data.continent) -
                        orderedContinents.indexOf(a.data.continent)
                })
                d.children.forEach(function (d) {
                    d.sort(function (a, b) { return b.value - a.value })
                })
                d.treemap = d3.treemap().tile(d3.treemapResquarify)

                // The treemap layout must be given a root node, so we make a copy of our
                // child node, which creates a new tree from the branch.
                d.treemapRoot = d.copy()
            })
            .merge(types)
            .each(function (d) {
                // UPDATE + ENTER
                d.treemap.size([x1.bandwidth(), y(d.value)])(d.treemapRoot)
            })

        // d3.hierarchy gives us a convenient way to access the parent datum. This line
        // adds an index property to each node that we'll use for the transition delay.
        root.each(function (d) { d.index = d.parent ? d.parent.children.indexOf(d) : 0 })

        types.transition(t)
            .delay(function (d, i) { return d.parent.index * 150 + i * 50 })
            .attr('transform', function (d) {
                return 'translate(' + x1(d.data.type) + ',' + (height - y(d.value)) + ')'
            })

        var continents = types.selectAll('.continent')
            // Note that we're using our copied branch.
            .data(function (d) { return d.treemapRoot.children },
                  function (d) { return d.data.continent })

        continents = continents.enter().append('g')
            .attr('class', 'continent')
            .merge(continents)

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

        var enterCountries = countries.enter().append('rect')
            .attr('class', 'country')
            .attr('x', function (d) { return d.value ? d.x0 : x1.bandwidth() / 2 })
            .attr('width', function (d) { return d.value ? d.x1 - d.x0 : 0 })
            .attr('y', 0)
            .attr('height', 0)
            .style('fill', function (d) { return color(d.parent.data.continent) })

        countries = countries.merge(enterCountries)

        enterCountries
            .on('mouseover', function (d) {
                svg.classed('hover-active', true)
                countries.classed('hover', function (e) {
                    return e.data.country === d.data.country
                })
            })
            .on('mouseout', function () {
                svg.classed('hover-active', false)
                countries.classed('hover', false)
            })
            .on('click', function (d) {
                options.country = options.country === d.data.country ? null : d.data.country
                update()
            })
            .append('title')
            .text(function (d) { return d.data.country })

        countries.filter(function (d) { return d.data.country === options.country })
            .each(function (d) { d3.select(this.parentNode).raise() })
            .raise()

        countries
            .transition(t)
            .attr('x', function (d) { return d.value ? d.x0 : x1.bandwidth() / 2 })
            .attr('width', function (d) { return d.value ? d.x1 - d.x0 : 0 })
            .attr('y', function (d) { return d.value ? d.y0 : d.parent.parent.y1 / 2 })
            .attr('height', function (d) { return d.value ? d.y1 - d.y0 : 0 })
    }
}

</script>
</body>