Using Google Maps API to Implement a Two-dimensional Circle Heatmap like Tableau

Google Maps API V3 has a feature named Heatmap Layer to dipict the intensity of data at geographical points. It can satisfy our needs in most cases. However, if we want clickable data points and two-dimension weight (use radius and color to represent different properties of the point), we are on our own.

This article shows how to implement a heatmap layer in Google Map with these features:

  1. Use circles to stand for data points. Either the radius or the color of a circle can represent a property of the data point. You can also use both of them to get a two-dimensional heatmap.
  2. Data points are clickable, you can customize the pop-up information window.
  3. Automatically generate a color spectrum for the heatmap.

Demo

Please see the demos below. I implement two earthquakes heatmaps using the real-time GeoJSON data from USGS. The first one is implemented with Google’s built-in Heatmap layer. The second one creates a custom heatmap layer with Google Maps API’s Marker and OverlayView. The color of each point stands for the magnitude, and the size represents the significance, which is determined on a number of factors, including: magnitude, maximum MMI, felt reports, and estimated impact.

Google Heatmap Layer

Earthquakes: null
Date: null

Custom Heatmap Layer

Earthquakes: null
Date: null

Implementation

I implement a class named CircleHeatMapLayer. It inherits Google Maps API’s OverlayView so it can be used just like the other built-in layers. It’s ready-to-use but has three dependencies, which are JQuery, Google Maps API’s visualization lib, and RainbowVis-JS.

CircleHeatMapLayer Class

The source code of the custom layer is as below.

circleheatmap.jsview raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
function CircleHeatMapLayer(data, options) {
this.data = data || {};
this.options = {
color: {
binding: false,
spectrum: ['lime', 'yellow', 'red'],
minSpectrumVal: 0,
maxSpectrumVal: 400,
default: '#dedede'
},
radius: {
binding: false,
minRadiusVal: 200,
maxRadiusVal: 400,
maxRadius: 10,
default: 1
},
opacity: 0.7,
stroke: {
weight: 0,
color: "#bcbcbc"
},
infoWindow: '<div class="PointInfo">{{lat}},{{lon}}</div>',
spectrum: true
};
$.extend(true,this.options, options);
if (this.options.color.binding) {
this.gradientColor = new Rainbow();
this.gradientColor.setSpectrumByArray(this.options.color.spectrum);
this.gradientColor.setNumberRange(this.options.color.minSpectrumVal, this.options.color.maxSpectrumVal);
}
this.lastInfoWindow = null;
this.spectrum = null;
}
CircleHeatMapLayer.prototype = new google.maps.OverlayView;
CircleHeatMapLayer.prototype.updateData = function(new_data) {
$.extend(true, this.data, new_data);
var layer = this;
$.each(new_data, function(key, val) {
layer.updateCircle(key);
});
};
CircleHeatMapLayer.prototype.appendSpectrum = function() {
var spectrum = document.createElement('canvas');
spectrum.style.position = 'absolute';
spectrum.style.bottom = '10px';
spectrum.style.right = '0px';
spectrum.style.width= '150px';
spectrum.style.height= '40px';
var ctx=spectrum.getContext("2d");
ctx.canvas.width = 150;
ctx.canvas.height = 40;
var g = ctx.createLinearGradient(20,0,120,0);
var interval = 1.0/(this.options.color.spectrum.length - 1);
for (var i = 0; i < this.options.color.spectrum.length; i++) {
g.addColorStop(i * interval, this.options.color.spectrum[i]);
}
ctx.fillStyle=g;
ctx.fillRect(20,25,100,10);
ctx.fillStyle="#000";
ctx.font="15px Georgia";
ctx.fillText(this.options.color.minSpectrumVal,20,20);
ctx.fillText(this.options.color.maxSpectrumVal,110,20);
spectrum.id = this.getMap().getDiv().id + '-spectrum';
this.getMap().getDiv().parentNode.appendChild(spectrum);
this.spectrum = spectrum;
}
CircleHeatMapLayer.prototype.updateCircle = function(x) {
var layer = this;
if (this.data.hasOwnProperty(x) && this.data[x].hasOwnProperty('lat')) {
var node = this.data[x];
var size = this.options.radius.default;
if (this.options.radius.binding) {
if (node[this.options.radius.binding] > this.options.radius.maxRadiusVal) {
size = this.options.radius.maxRadius;
} else if (node[this.options.radius.binding] < this.options.radius.minRadiusVal) {
size = this.options.radius.maxRadius * (this.options.radius.minRadiusVal/this.options.radius.maxRadiusVal);
} else {
size = this.options.radius.maxRadius * (node[this.options.radius.binding]/this.options.radius.maxRadiusVal);
}
}
if (!node.hasOwnProperty('circle')) {
node.circle = new google.maps.Marker({
position: new google.maps.LatLng(node.lat, node.lon),
icon: {
path: google.maps.SymbolPath.CIRCLE,
fillColor: this.options.color.binding?("#"+this.gradientColor.colorAt(node[this.options.color.binding])):
this.options.color.default,
fillOpacity: this.options.opacity,
strokeColor: this.options.stroke.color,
strokeWeight: this.options.stroke.weight,
scale: size
},
zIndex: node[this.options.radius.binding]
});
node.circle.setMap(this.getMap());
node._key_ = x;
node.circle.infowindow = new google.maps.InfoWindow({
content: this.options.infoWindow.replace(/\{\{(.*?)\}\}/g, function(match, token) {
return node[token];
}),
position: new google.maps.LatLng(node.lat, node.lon)
});
google.maps.event.addListener(node.circle, 'click', function() {
if (layer.lastInfoWindow) {
layer.lastInfoWindow.close();
}
this.infowindow.open(this.getMap());
layer.lastInfoWindow = this.infowindow;
});
} else {
node.circle.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: this.options.color.binding?("#"+this.gradientColor.colorAt(node[this.options.color.binding])):
this.options.color.default,
fillOpacity: this.options.opacity,
strokeColor: this.options.stroke.color,
strokeWeight: this.options.stroke.weight,
scale: size
});
node.circle.setZIndex(node[this.options.radius.binding]);
node.circle.infowindow.setContent(this.options.infoWindow.replace(/\{\{(.*?)\}\}/g, function(match, token) {
return node[token];
}));
node.circle.setVisible(true);
}
}
}
CircleHeatMapLayer.prototype.onAdd = function() {
for (var x in this.data) {
this.updateCircle(x);
}
if (this.options.color.binding && this.options.spectrum) {
if (this.spectrum) {
this.spectrum.style.display = 'block';
} else {
this.appendSpectrum();
}
}
};
CircleHeatMapLayer.prototype.onRemove = function() {
for (var x in this.data) {
if (this.data.hasOwnProperty(x) && this.data[x].hasOwnProperty('lat')) {
var node = this.data[x];
if (node.hasOwnProperty('circle')) {
node.circle.setVisible(false);
}
}
}
if (this.options.color.binding && this.options.spectrum) {
this.spectrum.style.display = 'none';
}
};
CircleHeatMapLayer.prototype.draw = function() {};

How to Use

Let’s take the demo as an example. First we need to create a Google map:

HTML
1
2
3
4
5
6
7
<div style="position:relative; margin:auto;height:400px; width:800px;">
<div id='map' style="height:100%; width:100%;"></div>
<div style="position:absolute; bottom:0px; left: 0px; color:#000">
Earthquakes: <span class='num'>null</span><br/>
Date: <span class='time'>null</span>
</div>
</div>
JavaScript
1
2
3
4
5
6
7
8
9
10
var gmap_style_lightgreen= [{"stylers":[{"hue":"#baf4c4"},{"saturation":10}]},{"featureType":"water","stylers":[{"color":"#effefd"}]},{"featureType":"all","elementType":"labels","stylers":[{"visibility":"off"}]},{"featureType":"administrative","elementType":"labels","stylers":[{"visibility":"on"}]},{"featureType":"road","elementType":"all","stylers":[{"visibility":"off"}]},{"featureType":"transit","elementType":"all","stylers":[{"visibility":"off"}]}];
var myOptions = {
zoom: 1,
center: new google.maps.LatLng(30,0),
mapTypeId: google.maps.MapTypeId.ROADMAP,
styles: gmap_style_lightgreen
};
map = new google.maps.Map(document.getElementById('map'), myOptions);

Now we load the data from USGS and create the layer.

Load Earthquake Data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
var heatmap_data = {};
// Update @ Feb. 6, 2015. getJSON didn't work because any requests with parameters to the url will get a 403 code.
//$.getJSON('http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojsonp?callback=?')
$.ajax({
url:'http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojsonp',
jsonp: false,
jsonpCallback:'eqfeed_callback',
cache: true,
dataType:'jsonp'
});
eqfeed_callback = function(rst) {
$('.num').html(rst.metadata.count);
var generate_time = new Date(rst.metadata.generated);
$('.time').html(generate_time.toString());
$.each(rst.features, function(index, data) {
var point = {};
point.lat = data.geometry.coordinates[1];
point.lon = data.geometry.coordinates[0];
point.mag = data.properties.mag;
point.sig = data.properties.sig;
heatmap_data[data.id] = point;
});
heatmap_layer = new CircleHeatMapLayer(heatmap_data, {
color: {
binding: 'mag',
spectrum: ['black', 'yellow', 'red'],
maxSpectrumVal: 7
},
radius: {
binding: 'sig',
maxRadiusVal: 1000,
maxRadius: 20
},
infoWindow: '<div style="width:60px;overflow:hidden;height:50px">mag: {{mag}}</br><b>sig</b>: {{sig}}</div>'
});
heatmap_layer.setMap(map);
}

If you need to update some data points, you can use the updateData function of CircleHeatMapLayer, the corresponding circles will be redrawn. So you don’t have to render everything again. This is helpful when you get a lot of data points on the map.

^