Skip to main content

Mapbox choropleth map using vector tiles

This example shows how to setup a choropleth map using mapbo. The data used is the GINI Index of all US Counties from ACS 5-year (2017). Mapbox vector tiles are used as it provides better performance than rendering large amount of GeoJSON polygons. This code is adapted from Mapbox’s example on Join local JSON data with vector tile geometries.

If you would like to use GeoJSON with mapbox check out the Additional Notes below. It is much simpler than using vector tiles but it is discouraged to load all counties via GeoJSON due to the large size.

Completed choropleth

See the completed example here.

Setting up

Import libraries and styles in the head. You can download citysdk.js here or build it using browserify.

<script src="https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.js"></script>
<link
  href="https://api.tiles.mapbox.com/mapbox-gl-js/v1.2.0/mapbox-gl.css"
  rel="stylesheet"
/>
<script src="./citysdk.js"></script>

In the body setup a container with an id of map and add styles.

<style>
  body {
    margin: 0;
    padding: 0;
  }
  #map {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 100%;
  }
</style>
<div id="map"></div>

In a script tag after the map div, we initialize the map with mapbox accessToken you can get from here

mapboxgl.accessToken = "<your access token here>";
var map = new mapboxgl.Map({
  container: "map",
  style: "mapbox://styles/mapbox/streets-v11",
  center: { lat: 37.0902, lng: -95.7129 },
  zoom: 3
});

Adding the Vector tiles

Census Bureau provides vector tiles via ArcGIS REST Services for various years and geographic levels.

To locate the vector layer we want first find the year then the geography level id. So for 2017 and county level we can look for 2017 and 050 using find in your browser.

Here are a few geographic level ids for common levels:

010 = United States

020 = Region

030 = Division

040 = State

050 = ….. County

060 = ….. ….. County Subdivision

140 = ….. ….. Census Tract

860 = ….. 5-Digit ZIP Code Tabulation Area

Find more here

Vector tile for 2017 counties (050)

The layer is Hosted/VT_2017_050_00_PY_D1. Lastly we have to get the source-layer name. Click into the Styles and find the source-layer value. (in this case it is “County”)

Vector tile's page, with style link underlined

"source-layer" : "County"

Now lets load the vector tiles layer.

map.on("load", function() {
  map.addLayer({
    id: "counties",
    type: "fill",
    source: {
      type: "vector",
      tiles: [
        "https://gis-server.data.census.gov/arcgis/rest/services/Hosted/VT_2017_050_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"
      ]
    },
    "source-layer": "County",
    paint: {
      "fill-opacity": 0.6,
      "fill-color": "blue"
    }
  });
});

vector tiles layer rendered

Query data

We use CitySDK to query for the GINI Index from the ACS 5-year.

census(
  {
    vintage: 2017,
    geoHierarchy: {
      county: "*"
    },
    sourcePath: ["acs", "acs5"],
    values: ["B19083_001E"]
  },
  function(error, response) {
    console.log(response);
  }
);

Merging data with vector tiles

Vector tiles do not originally contain the GINI data, so we need to “merge” the CitySDK data to the vector tiles. But the since the vector tiles and CitySDK data are from two different sources and types, the method used appends the CitySDK data with the fill color value to a mapbox style expression.

We refactor the map.on function to first load the CitySDK data, then generate the style expression, lastly load the vector tiles layer with styles.

To generate a color expression, we first create a function to generate the colors using Chroma.js. Add load Chroma.js via script tag in the head, then in the CitySDK callback get all the GINI Index values into an array to pass into chroma.js to domain (range of values)

<script src="https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.0.4/chroma.min.js"></script>

chroma.scale('OrRd').padding(.15) will specific the color scale (Orange to Red) , .domain(values, 'q', 5) will use the GINI values to generate a quantiles groups for the values.

Now we can create a function that will take in any value and return a hex color code.

var values = response.map(function(county) {
  return county.B19083_001E;
}); //get all the GINI Index values , B19083_001E

var colorScale = chroma
  .scale("OrRd")
  .padding(0.15)
  .domain(values, "q", 5);

function getColor(val) {
  return colorScale(val).hex();
}

To generate the style expression for mapbox styling, we have to group the GEOIds that fall into the same quantile groups. So we first loop though each to create an object with color groups as keys and array of GEOIDs as values.

e.g. {“#FFF” : [“04343”, “04343”]}

Then we generate the expression by appending the GEOIDs and colors to the main match expression in the format below.

e.g. [‘match’, [‘get’, ‘GEOID’], [“04343”, “04343”], “#FFF”]

Lastly we append ‘rgba(0,0,0,0)’ for any GEOIDs that is not included in the expression rules.

var colors = {};

response.forEach(function(county) {
  var GEOID = county.state + county.county;
  var value = county.B19083_001E;
  var color = getColor(value);
  if (!colors[color]) {
    colors[color] = [];
  }
  colors[color].push(GEOID);
});

var colorExpression = ["match", ["get", "GEOID"]];
var colorQuantiles = Object.entries(colors).forEach(function([color, GEOIDs]) {
  colorExpression.push(GEOIDs, color);
});

colorExpression.push("rgba(0,0,0,0)");

From that we can add the layer the same way but instead of a static fill-color we can put into our style expression.

map.addLayer({
  id: "counties",
  type: "fill",
  source: {
    type: "vector",
    tiles: [
      "https://gis-server.data.census.gov/arcgis/rest/services/Hosted/VT_2017_050_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"
    ]
  },
  "source-layer": "County",
  paint: {
    "fill-opacity": 0.8,
    "fill-color": colorExpression
  }
});

Completed map and code should look like this.

Completed choropleth

map.on("load", function() {
  census(
    {
      vintage: 2017,
      geoHierarchy: {
        county: "*"
      },
      sourcePath: ["acs", "acs5"],
      values: ["NAME", "B19083_001E"]
    },
    function(error, response) {
      var values = response.map(function(county) {
        return county.B19083_001E;
      }); //get all the GINI Index values , B19083_001E
      var colorScale = chroma
        .scale("OrRd")
        .padding(0.15)
        .domain(values, "q", 5); // 5 quantiles

      function getColor(val) {
        return colorScale(val).hex();
      }

      //generate style expression
      var colors = {};

      response.forEach(function(county) {
        var GEOID = county.state + county.county;
        var value = county.B19083_001E;
        var color = getColor(value);
        if (!colors[color]) {
          colors[color] = [];
        }
        colors[color].push(GEOID);
      });

      var colorExpression = ["match", ["get", "GEOID"]];
      var colorQuantiles = Object.entries(colors).forEach(function([
        color,
        GEOIDs
      ]) {
        colorExpression.push(GEOIDs, color);
      });

      colorExpression.push("rgba(0,0,0,0)");

      map.addLayer({
        id: "counties",
        type: "fill",
        source: {
          type: "vector",
          tiles: [
            "https://gis-server.data.census.gov/arcgis/rest/services/Hosted/VT_2017_050_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"
          ]
        },
        "source-layer": "County",
        paint: {
          "fill-opacity": 0.8,
          "fill-color": colorExpression
        }
      });
    }
  );
});

Adding a popup

Add an click listener to the counties layer, then in the callback search up the GEOID of the county of the feature that was clicked and setHTML to the response values.

map.on("click", "counties", function(e) {
  var coordinates = e.lngLat;
  //look up GINI value
  var GEOID = e.features[0].properties.GEOID;
  var details = response.find(function(county) {
    var response_GEOID = county.state + county.county;
    return GEOID === response_GEOID;
  });

  new mapboxgl.Popup()
    .setLngLat(coordinates)
    .setHTML("GINI value for " + details.NAME + ": " + details.B19083_001E)
    .addTo(map);
});

Additional Notes

Using GeoJSON

For smaller queries using the GeoJSON provide the same results without the need of additional complex code to generate the color expression or look up the data. But note that load times might be longer as GeoJSON takes a while to pull from the Github/CDN.

map.on("load", function() {
  census(
    {
      vintage: 2017,
      geoHierarchy: {
        county: "*"
      },
      geoResolution: "5m",
      sourcePath: ["acs", "acs5"],
      values: ["NAME", "B19083_001E"]
    },
    function(error, response) {
      map.addLayer({
        id: "counties",
        type: "fill",
        source: {
          type: "geojson",
          data: response
        },
        paint: {
          "fill-opacity": 0.8,
          "fill-color": [
            "interpolate",
            ["linear"],
            ["get", "B19083_001E"],
            0.2,
            "#ffffb2",
            0.4,
            "#fecc5c",
            0.6,
            "#fd8d3c",
            0.8,
            "#f03b20",
            1,
            "#bd0026"
          ]
        }
      });

      map.on("click", "counties", function(e) {
        var coordinates = e.lngLat;
        //look up GINI value
        var properties = e.features[0].properties;

        new mapboxgl.Popup()
          .setLngLat(coordinates)
          .setHTML(
            "GINI value for " + properties.NAME + ": " + properties.B19083_001E
          )
          .addTo(map);
      });
    }
  );
});