Location Boston, MA
Interests Programming, Photography, Cooking, Game Development, Reading, Guitar, Running, Travel, Never having enough time...
This Site Made with Astro, Cloudinary , and many many hours of writing, styling, editing, breaking things , fixing things, and hoping it all works out.
Education B.S. Computer Science, New Mexico Tech
Contact site at dillonshook dot com
Random Read another random indie blog on the interweb
Subscribe Get the inside scoop and $10 off any print!
Leaflet Zip Code Map Part 2
In this part of the tutorial we’ll be focusing on what you need to do to get a front end set up to serve up the map and consume the wonderful zip code data.
In case you missed it, here’s what we’re going for:
HTML
The html required on the page is almost as basic as it gets since most stuff is loaded in dynamically.
The main pieces are:
The map div <div class="control" id="map-loader"></div>
The Coloring function dropdown if needed
<select id="map-color">
<option value="rev">Revenue</option>
<option value="revPerPop">Revenue / Population</option>
<option value="popPerRev">Population / Revenue</option>
<option value="medInc">Median Household Income</option>
<option value="medIncPerRev">Median Income / Revenue</option>
<option value="totMoney">Median Income * Population</option>
</select>
And your script includes of course:
<script src="/assets/js/underscore.min.js"></script>
<script src="/assets/js/d3/d3.v3.js"></script>
<script src="/assets/js/leaflet/leaflet.js"></script>
<script src="/assets/js/leaflet/leaflet.label.js"></script>
<script src="/assets/js/leaflet/numeral.js"></script>
<script src="/assets/js/map.js"></script>
I’m using Numeral.js for number formatting on the popup, d3 for the handy quantize function that assigns data to color buckets, and Underscore.js for filtering/mapping/aggregating the data received.
CSS
Most of the CSS is just for the age histogram in the rollover popup.
div.map-container{
position: relative;
min-height: 100%;
height: 100%;
margin: 0 0 -30px 0; /* the bottom margin is the negative value of the footer's height */
}
#map{
width: 100%;
height: 700px;
}
#age-graph, #age-labels
{
position:relative;
width:250px;
height:75px;
margin:8px;
padding:0;
}
#age-graph ul
{
position:absolute;
top:0;
display:block;
width:250px;
height:70px;
background-color:white;
margin:0;
}
#age-graph li
{
list-style:none;
position:absolute;
width:30px;
bottom:0;
text-align:center;
border:1px solid black;
visibility: hidden;
background-color:darkkhaki;
}
#age-labels
{
top: 10px;
height: 15px;
font-size:80%;
background: white;
}
JS
Saving the best and most for last, here’s the javascript. Thanks to stamen.com for a black and white tile set, it’s much nicer than struggling to tell what colors mean what when you overlay colored data on top.
/* map.js */
var map = null;
var zipLayer = null;
var resultStats = {
min: 0,
max: 0,
mean: 0,
variance: 0,
deviation: 0
};
var colorFunc = null; //the current function based on the dropdown
var apiResults = null; //last results we received
//load in the map if not already loaded, center the map on a selected point
//and set up the events/layers
initMap = function () {
clearError();
//get the center point of the map based on external parameters
$.ajax({
url: '/api/GetMapCenter'
}).done(function (results) {
if (map === null) {
map = L.map('map');
//add drag event that only happens every so often
var throttledDrag = _.throttle(dragEnd, 1500, { leading: false });
map.on('dragend', throttledDrag);
map.on('zoomend', throttledDrag);
L.tileLayer(
//'http://{s}.tile.osm.org/{z}/{x}/{y}.png', //another b&w tile server that stopped working for a bit
'http://{s}.tile.stamen.com/toner/{z}/{x}/{y}.png',
{
attribution: '© <a href="http://openstreetmap.org">OpenStreetMap</a> Contributors',
maxZoom: 18,
minZoom: 8
}
).addTo(map);
}
map.setView([results.latitude, results.longitude], 11);
//add center marker
L.marker([results.latitude, results.longitude], { title: 'Tunnel' })
.addTo(map);
fetchData();
}).fail(function (jqXHR, textStatus) {
tunnelData.errorLoading(jqXHR, textStatus);
});
}
var loading = false;
function showLoader() {
var html = '<h5>Loading</h5>'
+ '<div class="control-group span12 pagination-centered" style="font-weight:300; padding:1em 0 1em 0;">'
+ '<i class="icon-spinner icon-spin orange"></i> Loading Lots of Zip Data, please be patient...'
+ '</div>';
$('#map-loader').html(html);
loading = true;
}
function killLoader() {
$('#map-loader').empty();
loading = false;
}
//create the popup loaded with info on hover over a zip
function popup(feature, layer) {
if (feature.properties && feature.properties.zip) {
(function (layer, feature) {
var properties = feature.properties;
// Create a mouseover event
layer.on("mouseover", function (e) {
// Change the style to the highlighted version
layer.setStyle(zipHighlightStyle(feature));
// Create a popup with a unique ID linked to this record
var popup = $("<div></div>", {
id: "popup-" + properties.zip,
class: 'map-popup',
css: {
position: "absolute",
bottom: "10px",
left: "10px",
zIndex: 1002,
backgroundColor: "white",
padding: "8px",
border: "1px solid #ccc"
}
});
var content = '<div>';
content += '<h5><b>' + properties.zip + '</b></h5>';
content += '<p>Revenue ' + numeral(properties.rev).format('$0,0') + '</p>';
content += '<p>Quantity ' + numeral(properties.qty).format('0') + '</p>';
content += '<p>Population ' + numeral(properties.pop).format('0,0') + '</p>';
content += '<p>' + numeral(properties.home_pct_own / 100).format('0.0%') + ' own, ' +
numeral(properties.home_pct_rent / 100).format('0.0%') + ' rent </p>';
content += '<p>Median Age ' + properties.mAge + '</p>';
content += '<p>Median Income ' + numeral(properties.mInc).format('$0,0') + '</p>';
content += '<p>Color Function ' + numeral(colorFunc(properties)).format('0,0.00') + '</p>';
content += '<p>Age Breakdown</p>';
//histogram
content += '<div id="age-graph"><ul>';
content += '<li>' + properties.age_pct_0_19 + ':0-19</li>';
content += '<li>' + properties.age_pct_20_39 + ':20-39</li>';
content += '<li>' + properties.age_pct_40_59 + ':40-59</li>';
content += '<li>' + properties.age_pct_60_79 + ':60-79</li>';
content += '<li>' + properties.age_pct_80_over + ':>=80</li>';
content += '</ul></div>';
content += '</div><div id="age-labels"></div>';
var hed = $(content, { css: { fontSize: "16px", marginBottom: "3px" } })
.appendTo(popup);
popup.appendTo("#map");
makeGraph();
});
// Create a mouseout event that undoes the mouseover changes
layer.on("mouseout", function (e) {
layer.setStyle(zipStyle(feature));
$(".map-popup").remove();
});
})(layer, feature);
}
}
function makeGraph() {
var container = document.getElementById("age-graph");
var labels = document.getElementById("age-labels");
var dnl = container.getElementsByTagName("li");
for (var i = 0; i < dnl.length; i++) {
var item = dnl.item(i);
var innerHtml = item.innerHTML;
var content = innerHtml.split(":");
var value = content[0];
var label = content[1];
item.style.height = value + "%";
var leftOffset = (i * 40 + 20) + "px";
labels.innerHTML = labels.innerHTML +
'<span style="position:absolute;top:-16px;left:' + leftOffset + '">' + label + '</span>';
item.style.left = leftOffset;
item.innerHTML = value;
item.style.visibility = "visible";
}
}
function dragEnd(distance) {
fetchData();
}
var colorFunctions = {
rev: function (properties) {
return properties.rev;
},
revPerPop: function (properties) {
return properties.pop == 0 ?
0 :
properties.rev / properties.pop;
},
popPerRev: function (properties) {
return properties.rev == 0 ?
0 :
properties.pop / properties.rev;
},
medInc: function (properties) {
return properties.mInc;
},
medIncPerRev: function (properties) {
return properties.rev == 0 ?
0 :
properties.mInc / properties.rev;
},
qty: function (properties) {
return properties.qty;
},
totMoney: function (properties) {
return properties.mInc * properties.pop;
}
};
//determine the right color for a feature given its properties
function zipStyle(feature) {
var colorArray = [
'#5D16DA',
'#2F14D9',
'#1325D8',
'#1250D7',
'#117CD6',
'#0FA7D5',
'#0ED3D4',
'#0DD3A6',
'#0CD278',
'#0BD14A',
'#09D01C',
'#22CF08',
'#4ECE07',
'#7ACD06',
'#A6CC05',
'#CBC304',
'#CA9503',
'#C96702',
'#C83901',
'#C70A00',
];
//color values that want it
var colorRange = [];
for (var i = 1; i <= colorArray.length; i++) {
colorRange.push(i);
}
var colorFunc = colorFunctions[$('#map-color').val()];
var val = colorFunc(feature.properties);
var domainStart = Math.max(resultStats.mean - (2 * resultStats.deviation), 0);
var domainEnd = resultStats.mean + (2 * resultStats.deviation);
var bucketFunc = d3.scale.quantize().domain([domainStart, domainEnd]).range(colorRange);
//no color for you 0's
if (val == 0) {
return {
'color': '#888888',
'weight': 2,
'opacity': 0.8
};
}
var assignedBucket = bucketFunc(val);
return {
'color': colorArray[assignedBucket - 1],
'weight': 2,
'opacity': 0.8
};
}
function zipHighlightStyle(feature) {
var defStyle = zipStyle(feature);
defStyle.opacity = 1;
defStyle.weight = 5;
return defStyle;
}
//get the zip geoJSON data from the server
function fetchData() {
//don't load data with an outstanding request
if (loading) {
return;
}
clearError();
showLoader();
var mapCenter = map.getCenter();
var mapBounds = map.getBounds();
var params = {
latitude: mapCenter.lat,
longitude: mapCenter.lng,
NELat: mapBounds._northEast.lat,
NELng: mapBounds._northEast.lng,
SWLat: mapBounds._southWest.lat,
SWLng: mapBounds._southWest.lng,
extraFilterParametersYouShoulPass: null
};
$.ajax({
url: '/api/Map?' + $.param(params)
}).done(function (results) {
killLoader();
apiResults = results;
displayResults(apiResults);
}).fail(function (jqXHR, textStatus) {
errorLoading(jqXHR, textStatus);
killLoader();
});
}
//update the map once we have geoJSON data
function displayResults(results) {
//remove last zips
if (zipLayer !== null) {
map.removeLayer(zipLayer);
}
colorFuncChange();
calcMinMax(results.geoData.features);
zipLayer = L.geoJson(results.geoData, {
onEachFeature: popup,
style: zipStyle
});
zipLayer.addTo(map);
}
//calculate statistics about the data given our coloring function
function calcMinMax(features) {
var colorFuncVals = _.map(features, function (feature) {
return colorFunc(feature.properties);
});
var stats = calcStats(colorFuncVals);
stats.min = _.min(colorFuncVals);
stats.max = _.max(colorFuncVals);
resultStats = stats;
}
function calcStats(a) {
var r = { mean: 0, variance: 0, deviation: 0 }, t = a.length;
for (var m, s = 0, l = t; l--; s += a[l]);
for (m = r.mean = s / t, l = t, s = 0; l--; s += Math.pow(a[l] - m, 2));
return r.deviation = Math.sqrt(r.variance = s / t), r;
}
function colorFuncChange() {
colorFunc = colorFunctions[$('#map-color').val()];
}
$(function () {
$('#map-color').on('change', function (el) {
displayResults(apiResults);
});
initMap();
});
The basic flow is:
- Initialize the map with a center point retrieved from another API endpoint
- Fetch the zipcode data for the current viewport
- Calculate some statistics about the demographic data received that will be used to determine the color for each zip code.
- Set up all the bindings for zoom/scroll/hover events and handle appropriately.
Closing thoughts
At some point I would like to group the zip codes together by fewer and fewer digits and show bigger and bigger areas as you zoom out. This would allow the map to show the whole country instead of having to cap the max zoom because there’s so much data to show. This should be possible starting back with the original shape data file by finding the distinct lat/long points in each zip code group. This would probably take awhile to compute but once you got it into the database all the steps down the line would be much faster showing less boundary data.
Thanks for reading! Hopefully this has saved you a bunch of time making an awesome looking map and please leave a comment if it has.
Want the inside scoop?
Sign up and be the first to see new posts
No spam, just the inside scoop and $10 off any photo print!