sg-heatmap
Open-source all-in-one Swiss Army knife tool for creating Choropleth maps

Motivation
How do you generate a Choropleth map?
Step 1:
First get a bunch of polygons

Step 2:
Then get a ton of location data points

Step 3:
Assign each data point to an area (i.e. binning)
Step 4:
Aggregate the data points in each bin (area) with an aggregating function (eg. count, mean, median)
Step 5:
Map each bin's aggregated value to a color using a color scale
Step 6:
Render the colored polygons onto Google map
Step 7:
Received a new set of data points? Repeat Step 3 to Step 6
Clearly generating a Choropleth is not an easy task. Our goal is to provide a simple yet highly customizable JavaScript tool for data enthusiast to spend less time engineering and more time building beautiful visualizations.
Content
A basic example
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import {register_MEAN} from 'sg-heatmap/dist/helpers'
import {Spectral} from 'sg-heatmap/dist/helpers/color'
import dataPoints from './dataPoints.json'
var heatmap = new SgHeatmap( )
register_MEAN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
var colorScale = Spectral()
var renderer = heatmap.initializeRenderer(colorScale, {
strokeWeight: 1,
strokeColor: 'black',
strokeOpacity: 1,
fillColor: 'white',
fillOpacity: 0.7
})
renderer.setMap(googleMap)
heatmap.render('mean')
Binning by key / Working with pre-aggregated data
Sometimes we might be working with pre-aggregated data.
Instead of binning and updating with the location (lnglat),
you want to bin directly to each polygon using keys.
In this case we provides a helper function to modify your SgHeatmap object
import {insideByKey} from 'sg-heatmap/dist/helpers'
import aggregatedData from './aggregatedData.json'
insideByKey(heatmap)
aggregatedData.forEach(pt => {
heatmap.update(pt.keys, pt.wt)
})
One potential use case is doing the
(relatively time-consuming) binning and aggregating server-side
and send only the aggregated values to the client for rendering
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import {register_MEAN} from 'sg-heatmap/dist/helpers'
import dataPoints from './dataPoints'
var heatmap = new SgHeatmap()
register_MEAN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
var stat = heatmap.getStat('mean')
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import {register_LATEST} from 'sg-heatmap/dist/helpers'
var heatmap = new SgHeatmap()
register_LATEST(heatmap)
Object.keys(stat.values).forEach(key => {
heatmap.update([key], stat.values[key])
})
heatmap.render('latest')
NEW Plugin to support LeafletJS
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import supportLeaflet from 'sg-heatmap/dist/plugins/leaflet'
import {register_MEAN} from 'sg-heatmap/dist/helpers'
import {Spectral} from 'sg-heatmap/dist/helpers/color'
import dataPoints from './dataPoints.json'
var heatmap = new SgHeatmap( )
supportLeaflet(heatmap)
register_MEAN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
var colorScale = Spectral()
var renderer = heatmap.initializeRenderer(colorScale, {
weight: 1,
color: 'black',
opacity: 1,
fillColor: 'white',
fillOpacity: 0.7
})
renderer
.bindTooltip(layer => layer.feature.properties.Subzone_Name)
.addTo(leafletMap)
heatmap.render('mean')
NEW Plugin to support MapboxGL
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import supportMapboxGL from 'sg-heatmap/dist/plugins/mapboxgl'
import {register_MEAN} from 'sg-heatmap/dist/helpers'
import {Spectral} from 'sg-heatmap/dist/helpers/color'
import dataPoints from './dataPoints.json'
var heatmap = new SgHeatmap( )
supportMapboxGL(heatmap)
register_MEAN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
mapboxglMap.on('load', e => {
var colorScale = Spectral()
var renderer = heatmap.initializeRenderer(mapboxglMap, colorScale, {
'line-width': 1,
'line-color': 'black',
'line-opacity': 1,
'fill-color': 'white',
'fill-opacity': 0.7
})
var tooltip = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false
})
mapboxglMap.on('mousemove', renderer.layer, e => {
tooltip
.setLngLat(e.lngLat)
.setText(e.features[0].properties.Subzone_Name)
.addTo(mapboxgMap)
})
mapboxgMap.on('mouseleave', renderer.layer, e => {
tooltip.remove()
})
heatmap.render('mean')
})
NEW Plugin to support OpenLayers
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import supportOpenLayers from 'sg-heatmap/dist/plugins/openlayers'
import {register_MEAN} from 'sg-heatmap/dist/helpers'
import {Spectral} from 'sg-heatmap/dist/helpers/color'
import dataPoints from './dataPoints.json'
var heatmap = new SgHeatmap( )
supportOpenLayers(heatmap)
register_MEAN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
var colorScale = Spectral()
var defaultStyle = new ol.style.Style({
stroke: new ol.style.Stroke({
color: 'black',
width: 1
}),
fill: new ol.style.Fill({
color: 'white'
})
})
var renderer = heatmap.initializeRenderer(colorScale, defaultStyle)
renderer.setOpacity(0.7)
openLayersMap.addLayer(renderer)
heatmap.render('mean')
API Documentation
Installing
npm install --save sg-heatmap
Importing to project
import SgHeatmap from 'sg-heatmap'
var SgHeatmap = require('sg-heatmap')
Using predefined maps with polygon data loaded
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_region'
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_planning_area'
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
import SgHeatmap from 'sg-heatmap/dist/predefined/SPF_npc'
var heatmap = new SgHeatmap()
Impt: If using predefined maps browser-side, include json-loader in your webpack config
Data source:
Defining map with your own polygon data
import polygonData from './polygonData.json'
var heatmap = new SgHeatmap(polygonData.features)
Polygon data must takes the format of an array of GeoJSON feature objects
Position: [Number, Number]
LinearRing: Array<Position>
Polygon: {
type: 'Polygon',
coordinates: Array<LinearRing>,
bbox: [Number, Number, Number, Number]
}
MultiPolygon: {
type: 'MultiPolygon',
coordinates: Array<Array<LinearRing>>,
bbox: [Number, Number, Number, Number]
}
Feature: {
type: 'Feature'
id: String,
properties: Object,
geometry: Polygon | MultiPolygon
}
polygonData: Array<Feature>
Refer to relevant sections in IETF's 2015 GeoJSON Specification (RFC7946)
Defining the aggregating function
import {register_MEAN} from 'sg-heatmap/dist/helpers'
register_MEAN(heatmap)
List of predefined aggregating functions
- register_HISTORY
- register_LATEST
- register_COUNT
- register_SUM
- register_MEAN
- register_VARIANCE
- register_STDEV
- register_MIN
- register_MAX
- register_MEDIAN
register_HISTORY and register_LATEST does not do any actual aggregating
register_HISTORY simply push data point to an array in the update order while
register_LATEST replaces old value with each update and keeps only the latest data point
.update( ) method
import dataPoints from './dataPoints.json'
var pt = dataPoints[0]
heatmap.update([pt.lng, pt.lat], pt.wt)
pt = dataPoints[1]
heatmap.update([pt.lng, pt.lat], pt.wt)
dataPoints.slice(2).forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
This design supports streaming data. Each time .update( ) is called, binning and aggregating is performed on the single data point. Therefore .getStat( ) and .render( ) can be called even without all data points loaded
heatmap.resetState()
dataPoints.slice(0, 100).forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
heatmap.getStat('mean')
dataPoints.slice(100, 200).forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
heatmap.getStat('mean')
dataPoints.slice(200, 300).forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.wt)
})
heatmap.getStat('mean')
pt = dataPoints[0]
heatmap.bin([pt.lng, pt.lat])
var matchingKeys = heatmap.bin([pt.lng, pt.lat])
.map(child => child.key)
.getStat( ) method
var stat = getStat('mean') = {
stat: String,
values: Object,
unchanged: [String],
min: Number,
max: Number
}
Each data point only needs to be passed in once and any number of statistics can be called on the SgHeatmap Object
import {
register_MEAN, register_MAX, register_MIN
} from 'sg-heatmap/dist/helpers'
register_MEAN(heatmap)
register_MAX(heatmap)
register_MIN(heatmap)
dataPoints.forEach(pt => {
heatmap.update([pt.lng, pt.lat], pt.weight)
})
heatmap.getStat('mean')
heatmap.getStat('max')
heatmap.getStat('min')
.render( ) method
heatmap.initializeRenderer(colorScale, defaultStyle, addonStyle)
heatmap.render(key, domain)
- .initializeRenderer( ) method requires a colorScale function to be passed in as its first parameter (see below)
- defaultStyle and addonStyle are optional style options to be applied onto map polygons
- refer to https://developers.google.com/maps/documentation/javascript/3.exp/reference#Data.StyleOptions
- defaultStyle applies to every polygon (including those in the unchanged group)
- addonStyle applies to those polygons that has been assigned at least one data point
- do not set 'fillColor' in addonStyle as it will be overridden by the fillColor colorScale specify
colorScale function
Any function that maps numeric values between 0 and 1 to CSS colors
colorScale(0.5)
colorScale(1)
Using predefined colorScale
import {Spectral} from 'sg-heatmap/dist/helpers/color'
var colorScale = Spectral()
List of predefined colorScale
- eg. Spectral, YlOrRd, Purples
- Refer to COLORBREWER 2.0 for the full set of color schemes available
Using colorScale helper function to generate customized colorScale
import generateColorScale from 'sg-heatmap/dist/helpers/color'
var colorArray = ['white', 'yellow', 'orange', 'red', 'black']
var colorScaleOptions = {
transform: 1,
bezierInterpolate: false,
correctLightness: true,
interpolationMode: 'lab'
}
var customColorScale = getColorScale(colorArray, colorScaleOptions)
Refer to chroma.js docs for detail explanation of the different colorScaleOptions
Normalization and remapping of values
Since colorScale accepts input value only between 0 and 1 while stat.values can be any numeric value. Values in stat.values are first normalized before passing into colorScale. By default, we perform a linear interpolation with domain end points set to the min and max values
function normalize (value) {
return (value - stat.min) / (stat.max - stat.min)
}
You may set your own domain by providing it through an option argument in render( ). Eg.
heatmap.render('mean', {domain: [100, 1000]})
Sometimes linear mapping of value to color
may not visibly separate the different values sufficiently
(eg. majority of values are clustered in the lower range)
In this case, we may want to apply a power transformation
to accentuate difference within certain part of the domain.
The power transformation to apply can be specified in the same option argument. Eg.
heatmap.render('mean', {transform: 0.5})
heatmap.render('mean', {transform: 2})
Advance Topics
Adding Event Handlers
var defaultStyle = {
strokeOpacity: 0,
fillOpacity: 0
}
var addonStyle = {
strokeOpacity: 1,
fillOpacity: 0.7
}
var renderer = heatmap.initializeRenderer(defaultStyle, addonStyle)
renderer.addListener('click', event => {
var Address = event.feature.getProperty('Address')
var Subzone_Name = event.feature.getProperty('Subzone_Name')
console.log(Subzone_Name, Address)
})
Refer to
https://developers.google.com/maps/documentation/javascript/3.exp/reference#Data.Feature
for details on the methods available on the feature object
renderer.addListener('mouseover', event => {
renderer.overrideStyle(event.feature, {fillOpacity: 0.5})
})
renderer.addListener('mouseout', event => {
renderer.revertStyle(event.feature)
})
Refer to
https://developers.google.com/maps/documentation/javascript/3.exp/reference#Data
for a detailed list of methods on the renderer object
var renderer = heatmap.initializeRenderer(defaultStyle, addonStyle)
renderer.on({
click: event => {
var Address = event.layer.feature.properties.Address
var Subzone_Name = event.layer.feature.properties.Subzone_Name
console.log(Subzone_Name, Address)
},
mouseover: event => {
event.layer.setStyle({fillOpacity: 0.5})
},
mouseout: event => {
renderer.resetStyle(event.layer)
}
})
var renderer = heatmap.initializeRenderer(defaultStyle, addonStyle)
var clickHandler = new ol.interaction.Select()
openLayersMap.on('click', event => {
openLayersMap.forEachFeatureAtPixel(event.pixel, feature => {
var Address = feature.get('Address')
var Subzone_Name = feature.get('Subzone_Name')
console.log(Subzone_Name, Address)
})
})
Custom aggregate functions
When .update( ) is called binning and aggregation is performed simultaneously. How does this work? How does the SgHeatmap object aggregate before being exposed to the full dataset.
SgHeatmap does this by using a reducer approach in aggregation. Those who has worked with Redux.js will be familiar with this approach.
Each child of the SgHeatmap object (corresponding to one feature) has a state object
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
var heatmap = new SgHeatmap()
console.log(heatmap.children[0].state)
To enable aggregation, first you need to define a default state on all the children by calling .setDefaultState( ).
heatmap.setDefaultState({_count: 0, _sum: 0})
console.log(heatmap.children[0].state)
Then you register some updater functions by calling .registerUpdater( ). These updaters are reducer functions that requires two parameters newValue and oldState and returns a newState by performing some some update operations.
function countUpdater (newValue, oldState) {
return {_count: oldState._count + 1}
}
function sumUpdater (newValue, oldState) {
return {_sum: oldState._sum + newValue}
}
heatmap.registerUpdater(countUpdater)
heatmap.registerUpdater(sumUpdater)
heatmap.inspectUpdaters()
The final step is to register a compute statistic function by calling .registerStat( ). stat functions takes in a child's state and output a numeric statistic value. Only stat that has been registered are available to be called by the .getStat( ) method.
function computeMean (state) {
return state._sum / state._count
}
heatmap.registerStat('mean', computeMean)
heatmap.inspectStats()
To reset a heatmap and empty all it's data you can call .resetState( ) and all the children's state will be reverted to the defaultState.
heatmap.resetState()
console.log(heatmap.children[0].state)
If all these looks too complicated to you, just use one of the predefined aggregate functions. It should do everything for you. The predefined aggregate functions provided are more than enough for most use cases.
import {register_MEAN} from 'sg-heatmap/dist/helpers'
register_MEAN(heatmap)
Another alternative (for those who have problems wrapping their head around to writing reducer functions) is to just use a history updater and rely solely on the stat function for aggregating.
import {register_HISTORY} from 'sg-heatmap/dist/helpers'
register_HISTORY(heatmap)
function computeMean (state) {
var sum = state._history.reduce((s, v) => s + v, 0)
var count = state._history.length
return sum / count
}
registerStat('mean', computeMean)
The reducer design has a few advantages:
- SgHeatmap object holds only the data needed for rendering the choropleth map instead of the entire dataset. If state needs to be passed around, you'll have a much smaller footprint.
- Supports streaming data. You can do interesting things like say 'moving average'
heatmap.setDefaultState({_history: []})
function historyUpdater (newValue, oldState) {
var _history = [...state._history]
if (_history.length === 10) {
_history.shift( )
}
_history.push(newValue)
return {_history: _history}
}
heatmap.registerUpdater(historyUpdater)
function computeMean (state) {
var sum = state._history.reduce((s, v) => s + v, 0)
var count = state._history.length
return sum / count
}
heatmap.registerStat('movingAverage', computeMean)
Dynamic stat
There are times when it may not be feasible to pre-register all the stat we need upfront. For example:
import {register_MEAN} from 'sg-heatmap/dist/helpers'
register_MEDIAN(heatmap)
heatmap.getStat('median')
heatmap.registerStat('percentile25', compute25percentile)
heatmap.registerStat('percentile75', compute75percentile)
heatmap.getStat('percentile25')
heatmap.getStat('percentile75')
What if we are using a slider? (10th, 20th, 30th, ... , 90th percentile). Are we going to write one new stat for each percentile? In this case we need a more flexible way of defining your 'stat'. How about a stat function that accepts a payload?
To provide for such situation, we allow our .getstat( ) and .render( ) method to accept a function instead of a key string. What this mean is you can supplied a stat function directly rather than calling a pre-registered stat function.
function computePercentile (n) {
return function (state) {
}
}
heatmap.getStat(computePercentile(25))
heatmap.render(computePercentile(75), colorScale)
Cloning SgHeatmap object
import SgHeatmap from 'sg-heatmap/dist/predefined/URA_subzone'
var oldHeatmap = new SgHeatmap()
var newHeatmap = oldHeatmap.clone(true)
var serializedData = oldHeatmap.serialize(true)
var newHeatmap = new SgHeatmap(JSON.parse(serializedData))
Cloned SgHeatmap can retain original state of all its children but updaters and stats will still have to be re-registered
import {register_MEAN} from 'sg-heatmap/dist/helpers'
register_MEAN(oldHeatmap)
var newHeatmap = oldHeatmap.clone(true)
newHeatmap.inspectUpdaters( )
newHeatmap.inspectStats( )
newHeatmap.getStat('mean')
register_MEAN(newHeatmap)