Skip to main content

Mapbox choropleth map using vector tiles

This example shows how to setup a choropleth map using Mapbox. The data used is the Means of Transportation to Work - Table B08134 from ACS 5-year (2019). Mapbox vector tiles provides better performance when rendering large amount of polygons compared to GeoJSON. This code is adapted from Mapbox’s Data Joins tutorial.

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 or small geographies via GeoJSON due to the large size of such files.

Completed choropleth

See the completed example here. See additional example for tracts 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.

Currently the directory for the available services is disabled, but you can use the mapdata API to find your vector layers. https://data.census.gov/api/mapdata?vintage=2019. Modify the vintage year to match your query.

To locate the vector layer look for your geography level id or geography name. So for county level we can look for VT_2019_050 using find (ctrl + f) in your browser, or look for where displayName:County.

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

Vector tile for 2019 counties (050)

The layer that we need is Hosted/VT_2019_050_00_PY_D1. Next 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, then add a simple grey fill layer. Notice that we also put in the parameter of promoteId to be GEOID, this will be used as a foreign key to join to our CitySdk response.

map.on("load", function() {
  map.addSource("counties", {
    type: "vector",
    tiles: ["https://gis.data.census.gov/arcgis/rest/services/Hosted/VT_2019_050_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"],
    promoteId: "GEOID" // promote field to be used as a foreign key
  })

  map.addLayer({
    id: "counties-fill",
    type: "fill",
    source: "counties",
    "source-layer": "County",
    paint: {
      "fill-opacity": 0.8,
      "fill-color": "grey"
    }
  });
});

vector tiles layer rendered

Query data

We use CitySDK to query for variable B08134_001E (est Total) and B08134_011E (est Car, truck, or van)

census(
  {
    vintage: 2019,
    geoHierarchy: {
      county: "*"
    },
    sourcePath: ["acs", "acs5"],
    values: ["NAME", "B08134_001E", "B08134_011E"]
  },
  (error, response) => {
    console.log(response);
  }
);

Merging data with vector tiles

Vector tiles do not contain any values other than GEOID and Name, so we need to join the CitySDK data to the vector tiles using the setFeatureState method in Mapbox. setFeatureState needs to be run on each county (row) of your response.

In your CitySDK callback, for each row in your response create a GEOID then run the setFeatureState method passing in a first object with source (the source created using addSource), sourceLayer (found in the Styles json), and id (a value that matches with the promoteId). Then a second object with all other fields from your response.

(error, response) => {
  //console.log(response);

  response.forEach((row) => {
    const GEOID = row.state + row.county
    map.setFeatureState({
      source: "counties",
      sourceLayer: "County",
      //promote field to be used as the primary key
      id: GEOID
    }, {
      //promote fields to be styled or interacted with
      name: row.NAME,
      car_truck_van_count: row.B08134_011E,
      total : row.B08134_001E,
      value: row.B08134_011E / row.B08134_001E
    })
  })
}

We can style the layer using feature-state

map.setPaintProperty("counties-fill", "fill-color", [
  "interpolate",
  ["linear"],
  ["feature-state", "value"],
  0.3,
  "#fef0d9",
  0.6,
  "#fdcc8a",
  0.8,
  "#fc8d59",
  0.9,
  "#e34a33",
  1,
  "#b30000"
]);

And add popups using map.queryRenderedFeatures then pulling out the state

map.on("click", "counties-fill", e => {
  const features = map.queryRenderedFeatures(e.point, { layers: ["counties-fill"] });

  if (features.length > 0) {
    const { state, properties } = features[0]

    const percent = `${(state.value * 100).toFixed(2)}%`

    new mapboxgl.Popup()
      .setLngLat(e.lngLat)
      .setHTML(`Percent of people in <strong>${state.name}</strong> that travel to work using a car/truck/van: ${percent}`)
      .addTo(map);
  }
});

Completed map and code should look like this.

Completed choropleth

map.on("load", () => {
  //setup source and layer
  map.addSource("counties", {
    type: "vector",
    tiles: ["https://gis.data.census.gov/arcgis/rest/services/Hosted/VT_2019_050_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"],
    promoteId: "GEOID" // promote field to be used as a foreign key
  })

  map.addLayer({
    id: "counties-fill",
    type: "fill",
    source: "counties",
    "source-layer": "County",
    paint: {
      "fill-opacity": 0.8,
      "fill-color": "grey"
    }
  });

  //query using citysdk
  census(
    {
      vintage: 2019,
      geoHierarchy: {
        county: "*"
      },
      sourcePath: ["acs", "acs5"],
      values: ["NAME", "B08134_001E", "B08134_011E"]
    },
    (error, response) => {
      //bind data to source layer
      response.forEach((row) => {
        const GEOID = row.state + row.county
        map.setFeatureState({
          source: "counties",
          sourceLayer: "County",
          //promote field to be used as the primary key
          id: GEOID
        }, {
          //promote fields to be styled or interacted with
          name: row.NAME,
          car_truck_van_count: row.B08134_011E,
          total : row.B08134_001E,
          value: row.B08134_011E / row.B08134_001E
        })
      })

      //update paint to display choropleth
      map.setPaintProperty("counties-fill", "fill-color", [
        "interpolate",
        ["linear"],
        ["feature-state", "value"],
        0.3,
        "#fef0d9",
        0.6,
        "#fdcc8a",
        0.8,
        "#fc8d59",
        0.9,
        "#e34a33",
        1,
        "#b30000"
      ]);

      map.on("click", "counties-fill", e => {
        const features = map.queryRenderedFeatures(e.point, { layers: ["counties-fill"] });

        if (features.length > 0) {
          const { state, properties } = features[0]

          const percent = `${(state.value * 100).toFixed(2)}%`

          new mapboxgl.Popup()
            .setLngLat(e.lngLat)
            .setHTML(`Percent of people in <strong>${state.name}</strong> that travel to work using a car/truck/van: ${percent}`)
            .addTo(map);
        }
      });
    })
});

Census Tract Example

Data in tract level

Since we are using tracts, we use level 140 and find the endpoint of Hosted/VT_2019_140_00_PY_D1. Looking at the styles we find that the source-layer is CensusTract.

Note: Census Tract Vector Tiles will only load for zoom levels 9 and above. If you don’t see anything zoom in!

map.addSource("tracts", {
  type: "vector",
  tiles: ["https://gis.data.census.gov/arcgis/rest/services/Hosted/VT_2019_140_00_PY_D1/VectorTileServer/tile/{z}/{y}/{x}.pbf"],
  promoteId: "GEOID" // promote field to be used as a foreign key
})

map.addLayer({
  id: "tracts-fill",
  type: "fill",
  source: "tracts",
  "source-layer": "CensusTract",
  paint: {
    "fill-opacity": 0.8,
    "fill-color": "grey"
  }
});

Then we query CitySDK for all the tracts in state of California, calculate the GEOID, and lastly use the setFeatureState on the response. Lastly the tracts base on the values. I added an fill-opacity rule to hide ones without values (out of state or no population).

census(
{
  vintage: 2019,
  geoHierarchy: {
    state: "06",
    tract: "*"
  },
  sourcePath: ["acs", "acs5"],
  values: ["NAME", "B08134_001E", "B08134_011E"],
  statsKey: "3c04140849164b373c8b1da7d7cc8123ef71b7ab"
},
(error, response) => {
  response.forEach((row) => {
    const GEOID = row.state + row.county + row.tract
    map.setFeatureState({
      source: "tracts",
      sourceLayer: "CensusTract",
      id: GEOID
    }, {
      name: row.NAME,
      car_truck_van_count: row.B08134_011E,
      total: row.B08134_001E,
      value: row.B08134_011E / row.B08134_001E
    })
})

  //update paint to display choropleth
  map.setPaintProperty("tracts-fill", "fill-color", [
    "interpolate",
    ["linear"],
    ["feature-state", "value"],
    0.3,
    "#fef0d9",
    0.6,
    "#fdcc8a",
    0.8,
    "#fc8d59",
    0.9,
    "#e34a33",
    1,
    "#b30000"
  ]);

  //hide tracts without values
  map.setPaintProperty("tracts-fill", "fill-opacity", [
    "case",
    ["to-boolean", ["feature-state", "value"]],
    0.6,
    0
  ]);

}

Tip: If you want tracts in other states, you can run this block/ convert this into a function to take in a different FIPS code.

See the full code and example here.

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);
      });
    }
  );
});