Fork me on GitHub

Version 0.1.8 - Add Map Legend

Add a map Legend and Header Identifier on Hover

comment count

Test Case:

This map is generated from data in this Google Docs Spreasheet:
https://docs.google.com/spreadsheet/pub?key=0AhLWjwzZgPNGdEFib2pYeVg1VEU5U0w0MXV5Y254NlE&output=html

Code:

<style type="text/css">
#map_test_1 {
  height: 500px;
  width: 880px;
  margin: 3em 0;
}

.info {
  padding: 6px 8px;
  font: 14px/16px Arial, Helvetica, sans-serif;
  background: white;
  background: rgba(255,255,255,0.8);
  box-shadow: 0 0 15px rgba(0,0,0,0.2);
  border-radius: 5px;
}
.info h4 {
  margin: 0 0 5px;
  color: #777;
}
.uberMap-legend {
  clear: both;
  margin: 0px;
  padding: 0px;
  list-style: none;
}
.legend-item {
  float: left;
  width: 1.5em;
  height: 1.5em;
  text-align: center;
  padding: .5em .25em 0em .25em;
  font-weight: bold;
  font-size: 1.25em;
  opacity: 0.7;
  margin: 0px;
  color: black;
}
.zoomedIn .legend-item {
  opacity: 1;
}
</style>

<p>
  This map is generated from data in this Google Docs Spreasheet:<br />
  <a href="https://docs.google.com/spreadsheet/pub?key=0AhLWjwzZgPNGdEFib2pYeVg1VEU5U0w0MXV5Y254NlE&output=html">https://docs.google.com/spreadsheet/pub?key=0AhLWjwzZgPNGdEFib2pYeVg1VEU5U0w0MXV5Y254NlE&output=html</a>
</p>

<!-- These are shims for IE6,7,8 and the like "lesser-browsers" -->
<script src="/choropleth/js/es5-shim.min.js"></script>
<script src="/choropleth/js/json2.js"></script>

<!-- used for javascript mustache templating -->
<script src="/choropleth/js/mustache.js"></script>

<!-- You must set the height of the div for the map, yes this should be in an external file -->
<div id="map_test_1"></div>
<div id="map_info_area"></div>

<script src="/choropleth/js/tabletop.js"></script>
<script type="text/javascript" src="/choropleth/js/us-states.js"></script>
<link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.5/leaflet.css" />
<!--[if lte IE 8]>
    <link rel="stylesheet" href="http://cdn.leafletjs.com/leaflet-0.5/leaflet.ie.css" />
<![endif]-->
<script type="text/javascript" src="http://cdn.leafletjs.com/leaflet-0.5/leaflet.js"></script>
<script type="text/javascript">

  // Once all tests are complete this version is complete.
  // [Done] include all required .js files
  // [Done] use tabletop.js to load data table from google spreadsheet and turn geoJSON into real objects
  // [Done] make array to associate machine headers to human headers from spreadsheet
  // [Done] forEach spreadsheet-data-row display geoJSON object to map
  // [Done] forEach spreadsheet-data-row update the map control layer
  // [TODO] Add feedback to the control layer to show loading of map.


  // This should probably change to a jQuery on.doc.load thing...
  window.onload = function() { init() };

  // Initialize the different constants that this uses to get information from a Google-Drive spreadsheet
  var mapKey = "0AhLWjwzZgPNGdEFib2pYeVg1VEU5U0w0MXV5Y254NlE";
  var sheetName = 'Hunger Data'
  var geoJSONHeaderName = "ubergeojson";
  var headersSheetName = "uberMap_meta-data";

  var choroplethRangeHeaderName = "foodinsecurityrate";
  var choroplethRangeHeaderName_HumanReadable = "";
  var choroplethRangeHigh = 0;
  var choroplethRangeLow = 0;
  var choroplethRangeStep = 0;
  var choroplethRangeSteps = [];

  // Initialize map specific variables
  var defaultMapCenter = [41.05, -77.37]; // A good way to get this number is go to maps.google.com zoom to where you want to center the map - right click and "Drop LatLng marker" copy and paste that little set of numbers in here just the way it is.
  var defaultZoomLevel = 7; // Self explanitory: Sets the zoom level from 0 to 18 usually of how close you want to be to the earth when the map loads.  For Pennsylvania it's ~7

  // Constants that are needed in multiple places in the app below.
  var sheetHeaders = [];
  var masterGeoJSON = {
    "type": "FeatureCollection",
    "features": []
    };
  var geojson;
  var choroplethDataClasses = 9;

  // Is in case you're using liquid layouts (in this case bundeled with Jekyll) you need to set a mustache delimiter.
  var mustacheSetDelimiter = "{{={u{ }u}=}}";
  var controlPannel_clickState = mustacheSetDelimiter;
  var controlPannel_hoverState = mustacheSetDelimiter;
  var controlPannel_initState = mustacheSetDelimiter;
  var controlPannel_legend = "";

  var zoomToJSONObject = L.geoJson(JSON.parse('{"type":"FeatureCollection","features":[{"type":"Feature","id":"USA-PA","properties":{"fips":"42","name":"Pennsylvania"},"geometry":{"type":"Polygon","coordinates":[[[-79.76278,42.252649],[-79.76278,42.000709],[-75.35932,42.000709],[-75.249781,41.863786],[-75.173104,41.869263],[-75.052611,41.754247],[-75.074519,41.60637],[-74.89378,41.436584],[-74.740426,41.431108],[-74.69661,41.359907],[-74.828057,41.288707],[-74.882826,41.179168],[-75.134765,40.971045],[-75.052611,40.866983],[-75.205966,40.691721],[-75.195012,40.576705],[-75.069042,40.543843],[-75.058088,40.417874],[-74.773287,40.215227],[-74.82258,40.127596],[-75.129289,39.963288],[-75.145719,39.88661],[-75.414089,39.804456],[-75.616736,39.831841],[-75.786521,39.722302],[-79.477979,39.722302],[-80.518598,39.722302],[-80.518598,40.636951],[-80.518598,41.978802],[-80.518598,41.978802],[-80.332382,42.033571],[-79.76278,42.269079],[-79.76278,42.252649]]]}}]}'), {style: {opacity: 0, fillOpacity: 0, clickable: false}});

  // State Control
  var polygonHasFocus = false;
  var lastClickedLayer;
  var geoJSONLayers = [];

  //console.log(zoomToJSONObject.getBounds());

  // init() is where we actually go to the Google-Drive spreadsheet and load the data in.
  function init() {
    Tabletop.init( { key: mapKey,
                     callback: processSpreadsheetData,
                     wanted: [sheetName, headersSheetName]
                   });
  }

  // This is where we actually process all of the data from the Google-Drive spreadsheet
  // and populate the masterGeoJSON object to get it ready for the map display function below.
  function processSpreadsheetData(data, tabletop) {

    var choroplethRangeCandidate = [];

    // I leave these here to toggle the ability to check out these variables in the browser
    // - so I can see what we're actually pulling from the Google-Drive spreadsheet.

    // --- Before data processing variables --- //

    //console.log(data);
    //console.log(tabletop.sheets(headersSheetName));
    //console.log(tabletop.sheets(sheetName));
    //console.log(tabletop.sheets(sheetName).elements);

    // ---

    // this creates a sheetHeaders[] object that holds two different
    // - properties: humanReadable and machineReadable
    // - sheetHeaders[] is an array that we can step through using forEach (thank you ES5)
    // - to display all of the map properties for each map object when we get to updating the map control layer.
    tabletop.sheets(headersSheetName).elements.forEach( function(element, index) {
      sheetHeaders[index] = {
        machineReadable: tabletop.sheets(sheetName).column_names[index],
        humanReadable: element.uberheaderlabel
      };
      if (sheetHeaders[index].machineReadable === choroplethRangeHeaderName) {
        choroplethRangeHeaderName_HumanReadable = sheetHeaders[index].humanReadable;
      }
    });

    // browser-view toggle for debugging:
    //console.log("Headers:");
    //console.log(sheetHeaders);

    tabletop.sheets(sheetName).elements.forEach(function(element, rowIndex) {
      masterGeoJSON.features[rowIndex] = JSON.parse(element[geoJSONHeaderName]).features[0];
      tabletop.sheets(sheetName).column_names.forEach(function(headerName, headerIndex) {
        if (headerName !== geoJSONHeaderName) {
          masterGeoJSON.features[rowIndex].properties[headerName] = element[headerName];
        } else {
          // Do nothing
        }
      });
      //console.log("County Name Check: " + masterGeoJSON.features[rowIndex].properties['countyname'] + " - " + masterGeoJSON.features[rowIndex].properties['name']);
      choroplethRangeCandidate[rowIndex] = parseFloat(masterGeoJSON.features[rowIndex].properties[choroplethRangeHeaderName]);
    });

    choroplethRangeHigh = Math.max.apply(null, choroplethRangeCandidate);
    choroplethRangeLow = Math.min.apply(null, choroplethRangeCandidate);
    choroplethRangeStep = (choroplethRangeHigh - choroplethRangeLow);
    for (i=0;i<choroplethDataClasses;i++) {
      choroplethRangeSteps[i] = parseFloat(choroplethRangeLow + ((choroplethRangeStep / choroplethDataClasses) * i));
    }
    //console.log("Max Choropleth Range: " + choroplethRangeHigh);
    //console.log("Min Choropleth Range: " + choroplethRangeLow);
    //console.log("Steps = " + choroplethRangeSteps);


    // More browser-view toggles for debugging:

    // --- After data processing variables --- //

    //console.log(data);
    //console.log("masterGeoJSON:");
    //console.log(masterGeoJSON);
    //console.log(statesData);

    //Build the mustache templates
    var legendInstructions = '<p class="legend-instructions">Choose a county below for hunger statistics.</p>'
    controlPannel_initState += legendInstructions;
    controlPannel_hoverState += '<p class="legend-header">{u{countyname}u} County</p><p class="legend-sub-header">(Click for more information)</p>';

    controlPannel_clickState += "<ul>";
    sheetHeaders.forEach( function(sheetHeader, sheetHeaderIndex){
      if (sheetHeader.machineReadable !== geoJSONHeaderName) {
        controlPannel_clickState += "<li>" +
          '<span class="map-data-property label">' + sheetHeader.humanReadable + "</span>" +
          ": " +
          '<span class="map-data-property value">' + "{u{" + sheetHeader.machineReadable + "}u}" + "</span>" +
          "</li>";
      }
    });
    controlPannel_clickState += "</ul>";

    controlPannel_legend += '<p class="uberMap-legend-header">' + choroplethRangeHeaderName_HumanReadable + '</p>' +
     '<ul class="uberMap-legend">';
    choroplethRangeSteps.forEach( function(rangeStep, rangeIndex) {
      controlPannel_legend += '<li class="legend-item" style="background-color: ' + getColor(rangeStep) + '">' + Math.floor(rangeStep) + '</li>';
    });
    controlPannel_legend += '</ul>';

    controlPannel_clickState += '<div class="zoomedIn">' + controlPannel_legend + '</div>';
    controlPannel_hoverState += controlPannel_legend;
    controlPannel_initState += controlPannel_legend;

    //console.log(controlPannel_hoverState);

    // ---

    // Now that we have actually loaded all of the data from the Google-Drive spreadsheet
    // - go ahead and load masterGeoSJON up to the map.
    loadMapData(masterGeoJSON);
  }

  // Setup the map to center where you would like it to.  You can always to go maps.google.com and right click anywhere on the map and "Drop LatLng Marker".
  var map = L.map('map_test_1', {
    zoomControl: false,
    dragging: false,
    touchZoom: false,
    scrollWheelZoom: false,
    doubleClickZoom: false
  });

  // This is where you get your map tiles.  You can get your own free API key from cloudmade.com
  // - please replace my key with yours if you're using this code in your own project.
  var cloudmade = L.tileLayer('http://{s}.tile.cloudmade.com/{key}/{styleId}/256/{z}/{x}/{y}.png', {
    attribution: 'Map data &copy; 2011 OpenStreetMap contributors, Imagery &copy; 2011 CloudMade',
    key: 'c5007019bb4e4787afb0135c36690912',
    styleId: 86036
  }).addTo(map);

  // This is where we decide which colors the polygons we draw on the map will be.
  // - this needs to be re-written before version 1.0 to calculate the proper range
  // - from the data provided automatically to provide a 5 to 10 color change range.

  // [TODO] re-write getColor() to calculate the 5 to 10 color step automatically based on the data from the spreadsheet.
  function getColor(d) {
    return d >= choroplethRangeSteps[8]   ? '#800026' :
           d >= choroplethRangeSteps[7]   ? '#BD0026' :
           d >= choroplethRangeSteps[6]   ? '#E31A1C' :
           d >= choroplethRangeSteps[5]   ? '#FC4E2A' :
           d >= choroplethRangeSteps[4]   ? '#FD8D3C' :
           d >= choroplethRangeSteps[3]   ? '#FEB24C' :
           d >= choroplethRangeSteps[2]   ? '#FED976' :
           d >= choroplethRangeSteps[1]   ? '#FFEDA0' :
                                            '#FFFFCC';
  }

  // [TODO] re-write this to get the styles from a stylesheet instead of hard-coding them here.
  // ------  Maybe I won't actually do this since some of these don't really match a CSS standard.
  // ------  Maybe the we will css standard the colors?  Some of these things can be set as
  // ------  variables at the top of this document... perhaps we'll just set it there due to the
  // ------  styleing constraints set forth from our leaflet.js buddies

  function style(feature) {
    return {
      fillColor: getColor(feature.properties.foodinsecurityrate),
      weight: 1,
      opacity: 1,
      color: 'white',
      fillOpacity: 0.7
    };
  }

  var info = L.control();

  info.onAdd = function (map) {
    this._div = L.DomUtil.create('div', 'info'); // create a div with a class "info"
    this.update();
    return this._div;
  };

  // method that we will use to update the control based on feature properties passed
  info.update = function (props, featureEventType) {
    if (props) {
      if (featureEventType === "click") {
        this._div.innerHTML = Mustache.render(controlPannel_clickState, props);
      } else if (featureEventType === "hover") {
        this._div.innerHTML = Mustache.render(controlPannel_hoverState, props);
      }
    } else {
      this._div.innerHTML = Mustache.render(controlPannel_initState);
    }

  };


  // This is what happens when your mouse hovers over a map element.
  function highlightFeature(e) {
    var layer = e.target;

    if (!polygonHasFocus) {
      layer.setStyle({
        fillColor: '#78A700',
        fillOpacity: 0.8
      });

      if (!L.Browser.ie && !L.Browser.opera) {
        layer.bringToFront();
      }
      info.update(layer.feature.properties, "hover");
    }
  }

  function clickFeature(e) {
    var layer = e.target;

    if (polygonHasFocus) {
      map.fitBounds(zoomToJSONObject.getBounds());
      polygonHasFocus = false;
      geojson.resetStyle(lastClickedLayer);
      info.update();
      geoJSONLayers.forEach(function(thisLayer, layerArrayIndex) {
        geojson.resetStyle(thisLayer);
      });
    } else {
      if (!L.Browser.ie && !L.Browser.opera) {
        layer.bringToFront();
      }
      geoJSONLayers.forEach(function(thisLayer, layerArrayIndex) {
        if (thisLayer === layer) {
          geojson.resetStyle(thisLayer);
          thisLayer.setStyle({
            fillOpacity: 1,
            weight: 0
          });
        } else {
          thisLayer.setStyle({
            fillColor: '#9EA4A1',
            fillOpacity: 0.7,
            weight: 0
          });
        }
      });
      map.fitBounds(layer.getBounds());
      polygonHasFocus = true;
      lastClickedLayer = layer;
      info.update(layer.feature.properties, "click");
    }
  }

  // This is what happens when your mouse goes away from an element.
  function resetHighlight(e) {
    if (!polygonHasFocus) {
      geojson.resetStyle(e.target);
      info.update();
    }
  }

  // This is where we assign the behavior to each map element we draw.
  // [TODO] Add on click event
  //        - zoom in when first clicked - then update map control layer
  //        - zoom out when clicked again to pan back to the overall map view
  function onEachFeature(feature, layer) {
    geoJSONLayers.push(layer);
    layer.on({
      mouseover: highlightFeature,
      mouseout: resetHighlight,
      click: clickFeature
    });
  }

  // This is the bit where we load all the geoJSON information into the map.
  function loadMapData(geoJSONData) {
    zoomToJSONObject.addTo(map);

    geojson = L.geoJson(geoJSONData, {
      style: style,
      onEachFeature: onEachFeature
    }).addTo(map);

    map.fitBounds(zoomToJSONObject.getBounds());
    map.on({
      click: function() {
        map.fitBounds(zoomToJSONObject.getBounds());
        polygonHasFocus = false;
        geojson.resetStyle(lastClickedLayer);
        info.update();
        geoJSONLayers.forEach(function(thisLayer, layerArrayIndex) {
          geojson.resetStyle(thisLayer);
        });
      },
      zoomend: function() {
        if (polygonHasFocus) {
          map.panBy([150, 0]);
          geoJSONLayers.forEach(function(thisLayer, layerArrayIndex) {
            thisLayer.setStyle({
              weight: 1
            });
          });
        }
      }
    });

    info.addTo(map);
  }

</script>