1465 words
~7min read

Leaflet Zip Code Map Part 2

September 24th 2014
3K views

Leaflet Zip Code Map Part 1

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:

leaflet js zipcode map

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: '&copy; <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!

comments powered by Disqus