d3 dates | d3 swimlane | | Search

This code creates an interactive heatmap visualization that displays time-series data, grouping it by day and coloring each day based on the total time spent. It uses D3.js to generate the SVG elements and apply the color scale.

Run example

npm run import -- "d3 calendar"

d3 calendar

var D3Node = require('d3-node');
var moment = require('moment');
var d3n = new D3Node();
var d3 = d3n.d3;

function d3Heatmap(data) {
    var margin = {top: 20, right: 50, bottom: 20, left: 20};

    // input vars for getter setters
    // TODO: turn these in to options
    var startYear = d3.min(data.map(d => d.start)).getFullYear(),
        endYear = d3.max(data.map(d => d.end)).getFullYear() + 1,
        years = d3.range(startYear, endYear).reverse(),
        colourRangeStart = '#666666',
        colourRangeEnd = '#000000',
        width = 950,
        height = years.length * 150,
        dayLength = 60 * 60 * 24,
        sizeByYear = (height - margin.top - margin.bottom) / years.length,
        sizeByDay = d3.min([
            // divide by 8, 7 days in a week and 1 row for label
            sizeByYear / 8,
            // 54 weeks because every year has a partial week on both ends
            (width - margin.left - margin.right) / 54]),
        day = (d) => (d.getDay() + 6) % 7,
        week = d3.timeFormat('%W'),
        date = d3.timeFormat('%b %d');

    // combine date data by day
    var nestedData = d3.nest()
        // round down to nearest day
        .key((d) => Math.round(d.start.getTime() / dayLength / 1000) * dayLength)
        // sum hours spent for the day to determine color range
        .rollup(function (n) { 
            return d3.sum(n, function (d) { 
                return d.end.getTime() - d.start.getTime(); // key
            }); 
        })
        .map(data)
    
    var svg = d3n.createSVG(width, height)
        .attr('class', 'chart')
        .append('g')
            .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

    var year = svg.selectAll('.year')
        .data(years)
        .enter().append('g')
            .attr('class', 'year')
            .attr('transform', (d, i) => 'translate(30,' + i * sizeByYear + ')');

    year.append('text')
        .text((d) => d)
        .attr('class', 'year-title')
        .attr('transform', 'translate(-38,' + sizeByDay * 3.5 + ')rotate(-90)')
        .attr('text-anchor', 'middle')

    // apply the heatmap colours
    var colour = d3.scaleLinear()
        .range(['white', 'white', colourRangeStart, colourRangeEnd])
        .domain([-1, 0, .000001, 1])

    var rect = year.selectAll('.day')
        .data((d) => d3.timeDays(new Date(d, 0, 1), new Date(d + 1, 0, 1)))
        .enter().append('rect')
            .attr('fill', (d) => {
                const t = Math.round(d.getTime() / dayLength / 1000) * dayLength;
                // 12 hours of work is too much!
                const normalDay = nestedData['


+t] / 1000 / (dayLength / 2);
                return isNaN(normalDay) ? 'white' : colour(normalDay);
            })
            .attr('class', 'day')
            .attr('stroke', 'black')
            .attr('stroke-width', .5)
            .attr('width', sizeByDay)
            .attr('height', sizeByDay)
            .attr('x', (d) => week(d) * sizeByDay)
            .attr('y', (d) => day(d) * sizeByDay);

    year.selectAll('.month')
        .data(d => d3.timeMonths(new Date(d, 0, 1), new Date(d + 1, 0, 1)))
        .enter().append('path')
            .attr('stroke', 'black')
            .attr('stroke-width', 2)
            .attr('class', 'month')
            .attr('fill', 'none')
            .attr('d', monthPath);

    // day and week titles
    var weekDays = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'],
        month = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];

    var titlesDays = svg.selectAll('.year')
        .selectAll('.titles-day')
        .data(weekDays)
        .enter().append('g')
        .attr('class', 'titles-day')
        .attr('transform', (d, i) => 'translate(-5,' + sizeByDay*(i+1) + ')');

    titlesDays.append('text')
        .attr('class', (d, i) => weekDays[i])
        .style('text-anchor', 'end')
        .attr('dy', '-.25em')
        .text((d, i) => weekDays[i]); 

    var titlesMonth = svg.selectAll('.year')
        .selectAll('.titles-month')
            .data(month)
        .enter().append('g')
            .attr('class', 'titles-month')
            .attr('transform', (d, i) => 'translate(' + ((i+1) * (sizeByDay * 52) / 12 - sizeByDay) + ',-5)');

    titlesMonth.append('text')
        .attr('class', (d, i) => month[i])
        .style('text-anchor', 'end')
        .text((d,i) => month[i]);

    // thick line around each month, with a turn in between week
    function monthPath(t0) {
        var t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0),
            d0 = +day(t0), w0 = +week(t0),
            d1 = +day(t1), w1 = +week(t1);

        return 'M' + (w0 + 1) * sizeByDay + ',' + d0 * sizeByDay
            + 'H' + w0 * sizeByDay + 'V' + 7 * sizeByDay 
            + 'H' + w1 * sizeByDay + 'V' + (d1 + 1) * sizeByDay
            + 'H' + (w1 + 1) * sizeByDay + 'V' + 0
            + 'H' + (w0 + 1) * sizeByDay + 'Z';
    }

    return d3n.svgString();
}

module.exports = d3Heatmap;

What the code could have been:

const d3 = require('d3/d3');
const moment = require('moment');
const D3Node = require('d3-node');

class D3Heatmap {
    constructor(data) {
        this.data = data;
        this.margin = { top: 20, right: 50, bottom: 20, left: 20 };
    }

    getYears() {
        const startYear = d3.min(this.data.map(d => moment(d.start).year())),
            endYear = d3.max(this.data.map(d => moment(d.end).year())).year() + 1;
        return d3.range(startYear, endYear).reverse();
    }

    getNestedData() {
        const nestedData = d3.nest()
           .key((d) => moment(d.start).startOf('day').toISOString())
           .rollup(function (n) {
                return d3.sum(n, function (d) {
                    return moment(d.end).diff(moment(d.start));
                });
            })
           .map(this.data);
        return nestedData;
    }

    getColourScale() {
        return d3.scaleLinear()
           .range(['#666666', '#000000'])
           .domain([-1, 0, 100, 100000]);
    }

    getSvg() {
        const svg = d3.select(this.container)
           .append('svg')
           .attr('width', 950)
           .attr('height', this.years.length * 150);
        return svg;
    }

    getYearsTitle() {
        const yearTitle = this.years.map((year, index) => {
            const title = this.svg.selectAll('.year')
               .data(this.years)
               .enter().append('g')
               .attr('class', 'year')
               .attr('transform', (d, i) => `translate(30,${index * 150})`);
            title.append('text')
               .attr('class', 'year-title')
               .attr('transform', 'translate(-38,135)rotate(-90)')
               .attr('text-anchor','middle')
               .text(d => year);
            return title;
        });
        return yearTitle;
    }

    getDaysRects() {
        const daysRects = this.yearsTitle.map(yearTitle => {
            const days = yearTitle.selectAll('.day')
               .data(d => d3.timeDays(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
               .enter().append('rect')
               .attr('fill', (d) => {
                    const t = moment(d).startOf('day').toISOString();
                    const normalDay = this.nestedData['


 + t] / 1000 / (moment(d).diff(moment(d.startOf('day'))) / 1000);
                    return isNaN(normalDay)? 'white' : this.colourScale(normalDay);
                })
               .attr('class', 'day')
               .attr('stroke', 'black')
               .attr('stroke-width', 0.5)
               .attr('width', 60)
               .attr('height', 60)
               .attr('x', (d) => d.getWeek() * 60)
               .attr('y', (d) => d.getDay() * 60);
            return days;
        });
        return daysRects;
    }

    getMonthPaths() {
        const monthPaths = this.yearsTitle.map(yearTitle => {
            const months = yearTitle.selectAll('.month')
               .data(d => d3.timeMonths(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
               .enter().append('path')
               .attr('stroke', 'black')
               .attr('stroke-width', 2)
               .attr('class','month')
               .attr('fill', 'none')
               .attr('d', t0 => this.monthPath(t0));
            return months;
        });
        return monthPaths;
    }

    getMonthPath(t0) {
        const t1 = new Date(t0.getFullYear(), t0.getMonth() + 1, 0),
            d0 = +moment(t0).day(),
            w0 = +moment(t0).week(),
            d1 = +moment(t1).day(),
            w1 = +moment(t1).week();
        return 'M' + (w0 + 1) * 60 + ',' + d0 * 60
            + 'H' + w0 * 60 + 'V' + 7 * 60
            + 'H' + w1 * 60 + 'V' + (d1 + 1) * 60
            + 'H' + (w1 + 1) * 60 + 'V' + 0
            + 'H' + (w0 + 1) * 60 + 'Z';
    }

    getTitles() {
        const titlesDay = this.yearsTitle.map(yearTitle => {
            const days = yearTitle.selectAll('.titles-day')
               .data(weekDays)
               .enter().append('g')
               .attr('class', 'titles-day')
               .attr('transform', (d, i) => `translate(-5,${(i + 1) * 60})`);
            days.append('text')
               .attr('class', (d, i) => weekDays[i])
               .style('text-anchor', 'end')
               .attr('dy', '-.25em')
               .text(d => weekDays[i]);
            return days;
        });

        const titlesMonth = this.yearsTitle.map(yearTitle => {
            const months = yearTitle.selectAll('.titles-month')
               .data(month)
               .enter().append('g')
               .attr('class', 'titles-month')
               .attr('transform', (d, i) => `translate(${((i + 1) * 60 * 52 / 12 - 60)},-5)`);
            months.append('text')
               .attr('class', (d, i) => month[i])
               .style('text-anchor', 'end')
               .text(d => month[i]);
            return months;
        });
        return { titlesDay, titlesMonth };
    }

    create() {
        this.container = document.createElement('div');
        this.svg = this.getSvg();
        this.years = this.getYears();
        this.yearsTitle = this.getYearsTitle();
        this.nestedData = this.getNestedData();
        this.colourScale = this.getColourScale();
        this.daysRects = this.getDaysRects();
        this.monthPaths = this.getMonthPaths();
        this.titles = this.getTitles();
    }

    render() {
        this.create();
        this.daysRects.forEach((daysRect, index) => {
            daysRect.attr('y', index * 150);
        });
        this.monthPaths.forEach((monthPath, index) => {
            monthPath.attr('transform', `(translate(30,${index * 150})`);
        });
        this.titles.titlesDay.forEach((titlesDay, index) => {
            titlesDay.attr('transform', `translate(-5,${(index + 1) * 60})`);
        });
        this.titles.titlesMonth.forEach((titlesMonth, index) => {
            titlesMonth.attr('transform', `(translate(${((index + 1) * 60 * 52 / 12 - 60)},-5))`);
        });
        return this.svg.node().outerHTML;
    }
}

module.exports = D3Heatmap;

This code generates a heatmap visualization of time-series data using D3.js.

Here's a breakdown:

  1. Initialization:

  2. Data Processing:

  3. SVG Setup:

  4. Year Labels:

  5. Heatmap Generation:

Let me know if you have any more questions.