block by patricksurry 803a131d4c34fde54b9fbb074341daa5

D3 + simpleheat.js geographic heatmap

Full Screen

Illustrates how to use Vladimir Agafonkin’s clever simpleheat JS library to overlay a heatmap of Hopper search destinations on a D3 map.

Just for fun we use a separate svg layer ‘under’ the canvas to display the map, although it’s easy enough to have D3 render direct to the canvas. The default canvas (and svg) ‘background’ is transparent so we can see through layers, making it easy to build up (say) an animated heatmap over a static map without continually redrawing the latter.

index.html

<!DOCTYPE html>
<html>
    <head>
<style>
#container svg, #container canvas {
    position: absolute;
    top: 0;
}
svg {
    background-color: black;
}
svg text {
    font-family: proxima-nova;
    font-size: 12px;
    fill: #666;
}
.countries {
    fill: #333333;
}
.airports {
    fill: #666666;
}
</style>
    </head>
    <body>
        <div id='container'>
        </div>
        <script src="https://d3js.org/d3.v4.min.js"></script>
        <script src="https://d3js.org/topojson.v1.min.js"></script>
        <script src="simpleheat.js"></script>  <!-- from https://github.com/mourner/simpleheat -->
        <script>

const width = 960;
const height = 600;

div = d3.select('#container');
mapLayer = div.append('svg').attr('id', 'map').attr('width', width).attr('height', height);
canvasLayer = div.append('canvas').attr('id', 'heatmap').attr('width', width).attr('height', height);

var canvas = canvasLayer.node(),
    context = canvas.getContext("2d");

// context.globalAlpha = 0.5;

var projection = d3.geoMercator().translate([width/2, height/2]),
    path = d3.geoPath(projection),
    airportMap;

d3.queue()
    .defer(d3.json, 'world-50m.json')
    .defer(d3.json, 'airports.json')
//    .defer(d3.csv, 'dests.csv')
    .defer(d3.csv, 'flexwatch.csv')
    .await(main);


function main(error, world, airports, dests) {
    airports.forEach(d => { d.coords = projection([d.longitude, d.latitude]); })
    airportMap = d3.map(airports, d => d.id);

    var countries = topojson.feature(world, world.objects.countries).features;

    mapLayer
        .append('g')
        .classed('countries', true)
        .selectAll(".country")
          .data(countries)
        .enter()
          .append("path")
          .attr("class", "country")
          .attr("d", path);

    mapLayer
      .append('g')
      .classed('airports', true)
      .selectAll('.airport')
        .data(airports)
      .enter().append('circle')
        .attr('r', 1)
            .attr('cx', function(d) { return d.coords && d.coords[0]; })
            .attr('cy', function(d) { return d.coords && d.coords[1]; })

    var heat = simpleheat(canvas);

    // set data of [[x, y, value], ...] format
    heat.data(dests.map(d => {a = airportMap.get(d.destination); return [a.coords[0], a.coords[1], +d.watches]}));

    // set point radius and blur radius (25 and 15 by default)
    heat.radius(10, 10);

    // optionally customize gradient colors, e.g. below
    // (would be nicer if d3 color scale worked here)
    // heat.gradient({0: '#0000ff', 0.5: '#00ff00', 1: '#ff0000'});

    // set maximum for domain
    heat.max(d3.max(dests, d => +d.watches));

    // draw into canvas, with minimum opacity threshold
    heat.draw(0.05);
}
        </script>
    </body>
</html>

simpleheat.js

'use strict';

if (typeof module !== 'undefined') module.exports = simpleheat;

function simpleheat(canvas) {
    if (!(this instanceof simpleheat)) return new simpleheat(canvas);

    this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;

    this._ctx = canvas.getContext('2d');
    this._width = canvas.width;
    this._height = canvas.height;

    this._max = 1;
    this._data = [];
}

simpleheat.prototype = {

    defaultRadius: 25,

    defaultGradient: {
        0.4: 'blue',
        0.6: 'cyan',
        0.7: 'lime',
        0.8: 'yellow',
        1.0: 'red'
    },

    data: function (data) {
        this._data = data;
        return this;
    },

    max: function (max) {
        this._max = max;
        return this;
    },

    add: function (point) {
        this._data.push(point);
        return this;
    },

    clear: function () {
        this._data = [];
        return this;
    },

    radius: function (r, blur) {
        blur = blur === undefined ? 15 : blur;

        // create a grayscale blurred circle image that we'll use for drawing points
        var circle = this._circle = this._createCanvas(),
            ctx = circle.getContext('2d'),
            r2 = this._r = r + blur;

        circle.width = circle.height = r2 * 2;

        ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
        ctx.shadowBlur = blur;
        ctx.shadowColor = 'black';

        ctx.beginPath();
        ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
        ctx.closePath();
        ctx.fill();

        return this;
    },

    resize: function () {
        this._width = this._canvas.width;
        this._height = this._canvas.height;
    },

    gradient: function (grad) {
        // create a 256x1 gradient that we'll use to turn a grayscale heatmap into a colored one
        var canvas = this._createCanvas(),
            ctx = canvas.getContext('2d'),
            gradient = ctx.createLinearGradient(0, 0, 0, 256);

        canvas.width = 1;
        canvas.height = 256;

        for (var i in grad) {
            gradient.addColorStop(+i, grad[i]);
        }

        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, 1, 256);

        this._grad = ctx.getImageData(0, 0, 1, 256).data;

        return this;
    },

    draw: function (minOpacity) {
        if (!this._circle) this.radius(this.defaultRadius);
        if (!this._grad) this.gradient(this.defaultGradient);

        var ctx = this._ctx;

        ctx.clearRect(0, 0, this._width, this._height);

        // draw a grayscale heatmap by putting a blurred circle at each data point
        for (var i = 0, len = this._data.length, p; i < len; i++) {
            p = this._data[i];
            ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
            ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
        }

        // colorize the heatmap, using opacity value of each pixel to get the right color from our gradient
        var colored = ctx.getImageData(0, 0, this._width, this._height);
        this._colorize(colored.data, this._grad);
        ctx.putImageData(colored, 0, 0);

        return this;
    },

    _colorize: function (pixels, gradient) {
        for (var i = 0, len = pixels.length, j; i < len; i += 4) {
            j = pixels[i + 3] * 4; // get gradient color from opacity value

            if (j) {
                pixels[i] = gradient[j];
                pixels[i + 1] = gradient[j + 1];
                pixels[i + 2] = gradient[j + 2];
            }
        }
    },

    _createCanvas: function () {
        if (typeof document !== 'undefined') {
            return document.createElement('canvas');
        } else {
            // create a new canvas instance in node.js
            // the canvas class needs to have a default constructor without any parameter
            return new this._canvas.constructor();
        }
    }
};