ethanhann/Diagrammatica

View on GitHub
src/js/heat-map.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';
/* global d3: false, check: false, ChartBase, isD3Selection: false, moment: false */
/* exported heatMap */
var HeatMapBase = function (selection, data) {
    this.data = data;
    selection = this.selection = isD3Selection(selection) ? selection : d3.select(selection);
    var chart = this.chart = new ChartBase(selection, 'heat-map');
    var config = this.config = chart.config;
    config.margin.top = 55;
    config.margin.bottom = 40;
    config.margin.right = 0;
    chart.updateDimensions();
    var dateRange = this.dateRange();
    this.fromX = moment(dateRange.from).toDate();
    this.toX = moment(dateRange.to).toDate();
};


HeatMapBase.prototype.filterDataByDateRange = function (data, unit) {
    var self = this;
    // Filter data by fromX and toX.
    return data.filter(function (d) {
        var m = moment(new Date(d.date));
        return (m.isAfter(self.fromX, unit) || m.isSame(self.fromX, unit)) &&
        m.isBefore(self.toX, unit) || m.isSame(self.toX, unit);
    });
};

HeatMapBase.prototype.getDates = function (data, unit) {
    var self = this;
    // Compute the set of dates that are equal to or after fromX and equal to or before toX.
    var dates = data.map(function (d) {
        return d.date;
    });
    return d3.set(dates)
        .values()
        .filter(function (d) {
            var m = moment(new Date(d));
            return (m.isAfter(self.fromX, unit) || m.isSame(self.fromX, unit)) &&
            m.isBefore(self.toX, unit) || m.isSame(self.toX, unit);
        });
};

HeatMapBase.prototype.dateRange = function () {
    var r = d3.extent(this.data.map(function (d) {
        return d.date;
    }));
    return {
        from: r[0],
        to: r[1]
    };
};

HeatMapBase.prototype.displayDateUnit = function () {
    return moment(this.toX).diff(this.fromX, 'months') >= 24 ? 'year' : 'month';
};

HeatMapBase.prototype.prepareDisplayData = function () {

    this.displayData = {
        data: this.filterDataByDateRange(this.data, 'month'),
        dateFormat: d3.time.format('%b %Y'),
        dates: this.getDates(this.data, 'month')
    };
    var dateUnit = this.displayDateUnit();
    if (dateUnit === 'year') {
        var x = d3.nest()
            .key(function (d) {
                return d.category;
            })
            .key(function (d) {
                return (new Date(d.date)).getFullYear();
            })
            .entries(this.data);
        var yearData = [];
        x.forEach(function (c) {
            c.values.forEach(function (s) {
                var date = moment(s.key, 'YYYY').toDate();
                yearData.push({
                    category: c.key,
                    date: date,
                    value: d3.sum(s.values, function (d) {
                        return d.value;
                    })
                });
            });
        });
        this.displayData.data = this.filterDataByDateRange(yearData, 'year');
        this.displayData.dates = this.getDates(yearData, 'year');
        this.displayData.dateFormat = d3.time.format('%Y');
    }
};

HeatMapBase.prototype.preRender = function () {
    var displayData = this.displayData;
    var chart = this.chart;
    var config = this.chart.config;
    this.updateCellPrimitives = function (data) {
        this.cellWidth = Math.floor(config.paddedWidth() / displayData.dates.length); // divide by number points of points on the x axis
        this.categories = d3.set(data.map(function (d) {
            return d.category;
        })).values();
        this.cellHeight = Math.floor(config.paddedHeight() / this.categories.length); // divide by number of categories
    };
    this.updateCellPrimitives(displayData.data);

    this.colors = ['#F7F9F5', '#F0F4EC', '#E1E9DA', '#D2DEC7', '#C3D4B5', '#B4C9A2', '#A5BE90', '#96B37D', '#87A96B'];
    this.buckets = this.colors.length;

    chart.xScale = d3.scale.linear();
    chart.yScale = d3.scale.ordinal();
    chart.colorScale = d3.scale.quantile();
    this.updateX = function (data) {
        this.updateCellPrimitives(data);
        chart.xScale
            .domain([0, displayData.dates.length - 1])
            .range([0, config.paddedWidth() - this.cellWidth]);
    };
    this.updateY = function (data) {
        chart.yScale
            .domain(data.map(function (d) {
                return d.category;
            }))
            .rangeRoundBands([0, config.paddedHeight() + 3]);
    };
    this.updateColors = function (data) {
        chart.colorScale
            .domain([0, this.buckets - 1, d3.max(data, function (d) {
                return d.value;
            })])
            .range(this.colors);
    };

    this.updateX(displayData.data);
    this.updateY(displayData.data);
    this.updateColors(displayData.data);
};

HeatMapBase.prototype.renderRectangles = function () {
    var chart = this.chart;
    var cellHeight = this.cellHeight;
    var cellWidth = this.cellWidth;
    var categories = this.categories;
    var displayData = this.displayData;
    var labelPadding = 6;
    this.yLabels = chart.renderArea.selectAll('.yLabel')
        .data(categories)
        .enter().append('text')
        .text(function (d) {
            return d;
        })
        .attr('x', -labelPadding / 2)
        .style('text-anchor', 'end')
        .attr('class', 'axis yLabel');

    if (!this.yLabels.empty()) {
        // Center y label based
        var maxYLabelHeight = d3.max(this.yLabels[0], function (d) {
            return d.getBoundingClientRect().height;
        });
        this.yLabels.attr('y', function (d) {
            return chart.yScale(d) + (cellHeight / 2) + (maxYLabelHeight / 4);
        });
        // Set left padding based on y max label width
        chart.config.margin.left = labelPadding + d3.max(this.yLabels[0], function (d) {
            return d.getBoundingClientRect().width;
        });
        chart.updateDimensions();
    }
    this.xLabels = chart.renderArea.selectAll('.xLabel')
        .data(displayData.dates)
        .enter().append('text')
        .text(function (d) {
            return displayData.dateFormat(new Date(d));
        })
        .attr('x', 0)
        .style('text-anchor', 'middle')
        .attr('transform', 'rotate(-90) translate(30, 0)')
        .attr('class', 'axis xLabel');

    if (!this.xLabels.empty()) {
        // Center x label in rect
        var maxXLabelHeight = d3.max(this.xLabels[0], function (d) {
            return d.getBoundingClientRect().height;
        });
        this.xLabels.attr('y', function (d, i) {
            return chart.xScale(i % displayData.dates.length) + (cellWidth / 2) + (maxXLabelHeight / 8);
        });
    }

    this.rectGroups = chart.renderArea.selectAll('g')
        .data(displayData.data)
        .enter()
        .append('g')
        .attr('transform', function (d, i) {
            var x = chart.xScale(i % displayData.dates.length);
            var y = chart.yScale(d.category);
            return 'translate(' + x + ',' + y + ')';
        });

    this.rectGroups.append('rect')
        .attr('width', cellWidth)
        .attr('height', cellHeight)
        .style('fill', function (d) {
            return chart.colorScale(d.value);
        });

    this.rectGroups
        .append('text')
        .text(function (d) {
            return d.value;
        })
        .attr('x', cellWidth / 2)
        .attr('y', cellHeight / 2)
        .attr('alignment-baseline', 'middle')
        .attr('text-anchor', 'middle');

    return this;
};

HeatMapBase.prototype.renderLegend = function () {
    var chart = this.chart;
    var config = this.config;
    var buckets = this.buckets;
    var colors = this.colors;
    var legendElementWidth = Math.floor(config.paddedWidth() / buckets);
    var legend = this.legend = chart.renderArea.append('g')
        .attr('class', 'legend');
    var legendItems = legend.selectAll('.legend-item')
        .data([0].concat(chart.colorScale.quantiles()), function (d) {
            return d;
        })
        .enter().append('g')
        .attr('class', 'legend-item');

    var swatchHeight = 19;
    legendItems.append('rect')
        .attr('x', function (d, i) {
            return legendElementWidth * i;
        })
        .attr('y', config.paddedHeight() + 10)
        .attr('width', legendElementWidth)
        .attr('height', swatchHeight)
        .style('fill', function (d, i) {
            return colors[i];
        });

    legendItems.append('text')
        .text(function (d) {
            return '≥ ' + Math.round(d);
        })
        .attr('x', function (d, i) {
            return legendElementWidth * i;
        })
        .attr('y', config.paddedHeight() + (swatchHeight * 2) + 2);

    return this;
};

HeatMapBase.prototype.render = function () {
    this.prepareDisplayData();
    this.preRender();
    this.renderRectangles();
    this.renderLegend();
    return this;
};

var heatMap = function (selection, data) {
    var heatMapBase = new HeatMapBase(selection, data).render();
    var chart = heatMapBase.chart;
    var update = heatMapBase.chart.update = function (newData) {
        data = heatMapBase.data = !check.undefined(newData) ? newData : heatMapBase.data;
        heatMapBase.prepareDisplayData();
        heatMapBase.updateColors(heatMapBase.displayData.data);
        heatMapBase.updateX(heatMapBase.displayData.data);
        heatMapBase.updateY(heatMapBase.displayData.data);
        heatMapBase.xLabels.remove();
        heatMapBase.yLabels.remove();
        heatMapBase.rectGroups.remove();
        heatMapBase.legend.remove();
        heatMapBase.render();
    };

    update.width = function (value) {
        return chart.width(value, function () {
            heatMapBase.updateY(heatMapBase.data);
        });
    };

    update.height = function (value) {
        return chart.height(value, function () {
            heatMapBase.updateX(heatMapBase.data);
        });
    };

    update.margin = function (value) {
        if (check.undefined(value)) {
            return chart.config.margin;
        }
        chart.config.margin = value;
        return update;
    };

    update.margin.left = function (value) {
        if (check.undefined(value)) {
            return chart.config.margin.left;
        }
        chart.config.margin.left = value;
        return update;
    };

    update.displayData = function () {
        return heatMapBase.displayData;
    };

    update.fromX = function (value) {
        if (check.undefined(value)) {
            return heatMapBase.fromX;
        }
        heatMapBase.fromX = value;
        return update;
    };

    update.toX = function (value) {
        if (check.undefined(value)) {
            return heatMapBase.toX;
        }
        heatMapBase.toX = value;
        return update;
    };

    return update;
};