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.
<!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>
'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();
}
}
};