Tutorials and hands-on lessons for learning

Data Joins

Add tabular data to a vector tileset using client-side data-joins.

View fullscreen demo


When adding custom data to a map, you may find that some of your data exist in a source that is separate from your geospatial data. For example, you might have a table of population data by country but the only spatial information included is the country name. In this case you need a way to ‘join’ the data to geometries, like country boundaries, to be able to visualize them on a map. In some cases, it can be tricky to prepare joined data before adding it to a map, for example, if the data are updating often, if the data file is large, or if you can’t directly edit the geometries source that you want to use (like a Mapbox Boundaries tileset).

Using a ‘client-side data-join’ lets you combine vector tile geometries with other data dynamically when your web map loads. In this tutorial, you will learn how to use the Feature State method to join a vector tile to an external data file, and then use data-driven style notation to visualize the data.

Getting started

There are a few resources you'll need to get started:

Templates:

Tools we'll use:

  • CSV to create and store your event data
  • Text Editor (Atom, VSCode, JSFiddle) to write and edit your code.‍
  • Mapbox account to access: ‍‍‍

    Mapbox Studio to create your map style.

    Mapbox GL JS to add interactivity to your map and publish it on the web‍‍.

  • Papa Parse to download and parse data from a CSV

Start your map code

Open a text editor and create a file called index.html. Set up the document by copying and pasting this template code into your new HTML file.

Add your Mapbox access token

Without an access token, the rest of the code will not work.‍

Login or create a free Mapbox account. Find your access token on your Access tokens page or the main page you sign into your Mapbox account.

Note: We recommend using the URL restriction feature on the token to avoid token misuse and want to emphasize that only public tokens should be posted to public repositories. Find out more information about how to securely manage your access tokens.

//YOUR TURN: Replace with your Mapbox Token
mapboxgl.accessToken =
  'pk.eyJ1IjoibWFwYm94LWNvbW11bml0eSIsImEiOiJja2tkN21jcjAwMG51MnBxdHAxemdueGpzIn0.e0IzLkytGq4pcGGieP8KNA';

Add a basemap style

There are several Mapbox-designed map styles that you can choose "out of the box" or you can design your own using the Mapbox Studio style editor. Let’s use Mapbox Light:


Add the Light Style to your map by replacing 'Replace with Mapbox style URL' with mapbox://styles/mapbox/light-v10.

//YOUR TURN: Replace with your Mapbox Token
mapboxgl.accessToken =
  'pk.eyJ1IjoibWFwYm94LWNvbW11bml0eSIsImEiOiJja2tkN21jcjAwMG51MnBxdHAxemdueGpzIn0.e0IzLkytGq4pcGGieP8KNA';
var map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/light-v10', // YOUR TURN: choose a style: https://docs.mapbox.com/api/maps/#styles
  zoom: 4.5, // zoom level
  center: [-120.4, 36.0], // center ][lng, lat]
  transformRequest: transformRequest
});

Connect your spreadsheet

The code uses Papa Parse to download and parse through the CSV export. In this example, we are pulling data stored in a GitHub repository. You can find the sample data here.

To connect your CSV, replace the ‘URL’ value with your CSV export link.

//YOUR TURN: Replace with a link to your CSV
const csvUrl =
  'https://raw.githubusercontent.com/mjdanielson/ca-districts-map/main/2018-CA-House-Results.csv';

To join the CSV data to the vector tiles, you'll need a field or column in your data that uniquely identifies each row or record AND can be used to match the corresponding boundary in the vector tileset. This field is referred to as the ‘primary key’. This example uses the field ‘district’ from the CSV data to match the district boundaries field ‘GEOID’ in the vector tileset.



Next, use map.setFeatureState to:

  1. Add the tileset source: ‘ca-districts-2018’. This is the name of the vector tileset that will be joined to the tabular data.
  2. Add the tileset source layer: ‘ca-districts-2018’.This is the name of the layer within the vector tileset used for the join. In this example, ‘ca-districts-2018’ only has one layer.
  3. Promote the field from the CSV that will be used as the primary key: row.district
  4. Promote the fields from the CSV data that will be styled or interacted with: row.party, row.candidate
map.on('load', function () {
        csvPromise.then(function (results) {
          console.log(results.data);
          results.data.forEach((row) => {
            map.setFeatureState(
              {
                //YOUR TURN: Replace with your source tileset and source layer
                source: 'ca-districts-2018',
                sourceLayer: 'ca-districts-2018',
                //YOUR TURN: Replace with unqiue ID row name
                id: row.district,
              },
              //YOUR TURN: Add rows you want to style/interact with
              {
                party: row.party,
                candidate: row.candidate,
              }
            );
          });
        });

Add source layer

Add the vector tile source layer to your map using map.addSource(‘ca-districts-2018’). Use the promoteId property to join the vector data to the CSV data.

//YOUR TURN: Add source layer
map.addSource('ca-districts-2018', {
  type: 'vector',
  url: 'mapbox://mapbox-community.ca-districts-2018',
  promoteId: 'GEOID'
});

Data driven-styling with expressions

Add a new layer using map.addLayer(). The source will be ‘ca-districts-2018’, and the source-layer will be ‘ca-districts-2018’.

You can use Mapbox GL JS ‘expressions’ to set the fill color of each feature according to the values found in the feature state. (Expressions use a Lisp-like syntax, represented using JSON arrays.) Expressions follow this format:

[expression_name, argument_0, argument_1, ...]

The expression_name is the expression operator, for example, you would use '*' to multiply two arguments or 'case' to create conditional logic. For a complete list of all available expressions see the Mapbox Style Specification.

The arguments are either literal (numbers, strings, or boolean values) or else themselves expressions. The number of arguments varies based on the expression.

In this example, you'll use a combination of expressions to style the data as a choropleth map:

  • match: Selects the output whose label value matches the input value, or the fallback value if no match is found.
  • feature-state: Use the feature-state expression to retrieve the value of the party property in the current feature's state and to assign a fill color to two different values of party: democrat "#6BA0C7", republican "#CE575E", and the default value is ‘#CCC”

When you put all these expressions together, your code will look like this:

//YOUR TURN: Add layers to the map
map.addLayer({
  id: 'ca-district-fill',
  type: 'fill',
  source: 'ca-districts-2018',
  'source-layer': 'ca-districts-2018',
  layout: {},
  paint: {
    'fill-color': [
      'match',
      ['feature-state', 'party'],
      'democrat',
      '#6BA0C7',
      'republican',
      '#CE575E',
      '#CCC'
    ],
    'fill-opacity': 0.9
  }
});

Final product

You created a choropleth map using data-joins. Now you can add popups, or additional layers such as district-lines to add more context to your map.

Publish your map

You’ve made a web map! But it isn’t a webpage yet… to do that we need some way to host a webpage. There are many different ways to host a webpage. Read this guide to learn how to publish your finished map on GitHub, Netlify, or Glitch.

Data Join with iOS Maps SDK v10.x

You can follow the installation and getting started guide for setting up your iOS application with Maps v10. For simplification we are not loading the .csv from a web resource here but have it already defined in a dictionary that can be found at the bottom of the implementation.

import UIKit
import MapboxMaps
import SwiftUI

class ViewController: UIViewController {
    
    internal var mapView: MapView!
    let sourceId = "states"
    let sourceLayerId = "ca-districts-2018"
    let promoteId = "GEOID"
    
    override public func viewDidLoad() {
        super.viewDidLoad()
        
        prepareMapWithStyle()
        self.view.addSubview(mapView)
        
        mapView.mapboxMap.onNext(.mapLoaded) { _ in
            
            self.addSource()
            
            let centerCoordinate = CLLocationCoordinate2D(latitude: 37.0, longitude: -120.4)
            let camera = CameraOptions(center: centerCoordinate, zoom: 4.2)
            self.mapView.mapboxMap.setCamera(to: camera)
        }
    }

There is a lot going on here already. prepareMapWithStyle() is implemented below and sets up our mapView in the viewController. Inside the eventHandler of onNext(.mapLoaded) we add an additional data source, which we will get to in the implementation of addSource() and finally we ask the mapView.mapboxMap to zoom to California.

    func prepareMapWithStyle() {
        let myResourceOptions = ResourceOptions(accessToken: "<redacted>") //add your access token here
        let myMapInitOptions = MapInitOptions(resourceOptions: myResourceOptions)
        mapView = MapView(frame: view.bounds, mapInitOptions: myMapInitOptions)
        mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        mapView.mapboxMap.loadStyleURI(StyleURI( url: URL( string: "mapbox://styles/mapbox/light-v10")!)!) { result in
        }
    }
    
    func addSource() {
        var vSource = VectorSource()
        vSource.url = "mapbox://mapbox-community.ca-districts-2018"
        vSource.promoteId = PromoteId.string("GEOID")
        
        try! self.mapView.mapboxMap.style.addSource(vSource, id: sourceId)
        mapView.mapboxMap.onNext(.sourceDataLoaded) { _ in
            
            self.addFillLayer()
            self.addLineLayer()
            
            //IMPORTANT! Timer because setFeatureState needs to be applied to a fully loaded data set.
            DispatchQueue.main.asyncAfter(deadline: .now()+0.3) { [weak self] in
                self!.setFeatureState()
            }
        }
    }

addSource() loads the tileset source, in this case we are using the public source of mapbox://mapbox-community.ca-districts-2018 which you probably want to replace with your private upload to the Mapbox Tiling service url. This represents the source which we later merge our additional data on to. As you can see, all we have to do is

  1. Declare the Mapbox url, where the vector tiles can be found. You get your own out of Mapbox Studio / Tilesets
  2. Declare a property to use as a feature id (for feature state). It can be either a property name, or an object of the form {<sourceLayer>: <propertyName>}. If specified as a string for a vector tile source, the same property is used across all its source layers. In this case we are promoting GEOID to be used as a unique identifier in the vector source.

As soon as the source data is loaded in onNext(.sourceDataLoaded) we can

  1. Add a layer that fills the districts based on the election results from 2018. We get into it in the implementation of addFillLayer()
  2. Add a layer that draws a line around the borders of the districts from our vector source in addLineLayer()
  3. Merge the additional data with the data source in setFeatureState(), which we will get into in a second.

Here we are adding a timer to make sure the vector tileset is fully loaded before we merge the data. It is more of a demo than a real life problem, because we have the data here in a dictionary already. In your implementation this would be the perfect time to load and parse the data.01

    func setFeatureState() {
        for item in data {
            self.mapView.mapboxMap.setFeatureState(sourceId: self.sourceId,
                                                   sourceLayerId: self.sourceLayerId,
                                                   featureId: item["district"]! ,
                                                   state: ["party": item["party"]!]
            )
        }
    }

In setFeatureState() we actually merge the data content onto the data source (our vector tiles). Remember we declared GEOID as featureId. Here it becomes important as this matches with the district from from our data set. After this function ran all the party information from data is now written onto each feature of the vector tiles on our device. This is important because addFillLayer evaluates these properties and looks for the party value on each feature to color it appropriately as you can see below.

    func addFillLayer() {
        var layer = FillLayer(id: "state-fills")
        layer.source = sourceId
        layer.sourceLayer = sourceLayerId
        layer.fillColor = .expression(
            Exp(.match) {
                Exp(.featureState){"party"}
                "democratic"
                UIColor.blue
                
                "republican"
                UIColor.red
                
                UIColor.clear
            }
        )
        try! self.mapView.mapboxMap.style.addLayer(layer)
    }

If your are unfamiliar with Mapbox expressions in iOS, you can dive deeper at expressionsBut basically the evaluation looks at party of each featureState of each feature of the data source and .matches them against democratic (-> blue), republican (-> red) and if none of them are true (-> clear) and puts the resulting value into layer.fillColor

    func addLineLayer() {
        var layer = LineLayer(id: "ca-district-line")
        layer.source = sourceId
        layer.sourceLayer = sourceLayerId
        layer.lineColor = .constant(StyleColor(UIColor.black))
        try! self.mapView.mapboxMap.style.addLayer(layer)
    }

And finally the data declaration. This would usually be data that you would host yourself so you can update it independently of the vector tiles.

    let data = [
        [
            "district" : "0601",
            "party" : "republican"
        ],
        [
            "district" : "0602",
            "party" : "democratic"
        ],
        [
            "district" : "0603",
            "party" : "democratic"
        ],
        [
            "district" : "0604",
            "party" : "republican"
        ],
        [
            "district" : "0605",
            "party" : "democratic"
        ],
        [
            "district" : "0606",
            "party" : "democratic"
        ],
        [
            "district" : "0607",
            "party" : "democratic"
        ],
        [
            "district" : "0608",
            "party" : "republican"
        ],
        [
            "district" : "0609",
            "party" : "democratic"
        ],
        [
            "district" : "0610",
            "party" : "democratic"
        ],
        [
            "district" : "0611",
            "party" : "democratic"
        ],
        [
            "district" : "0612",
            "party" : "democratic"
        ],
        [
            "district" : "0613",
            "party" : "democratic"
        ],
        [
            "district" : "0614",
            "party" : "democratic"
        ],
        [
            "district" : "0615",
            "party" : "democratic"
        ],
        [
            "district" : "0616",
            "party" : "democratic"
        ],
        [
            "district" : "0617",
            "party" : "democratic"
        ],
        [
            "district" : "0618",
            "party" : "democratic"
        ],
        [
            "district" : "0619",
            "party" : "democratic"
        ],
        [
            "district" : "0620",
            "party" : "democratic"
        ],
        [
            "district" : "0621",
            "party" : "democratic"
        ],
        [
            "district" : "0622",
            "party" : "republican"
        ],
        [
            "district" : "0623",
            "party" : "republican"
        ],
        [
            "district" : "0624",
            "party" : "democratic"
        ],
        [
            "district" : "0625",
            "party" : "democratic"
        ],
        [
            "district" : "0626",
            "party" : "democratic"
        ],
        [
            "district" : "0627",
            "party" : "democratic"
        ],
        [
            "district" : "0628",
            "party" : "democratic"
        ],
        [
            "district" : "0629",
            "party" : "democratic"
        ],
        [
            "district" : "0630",
            "party" : "democratic"
        ],
        [
            "district" : "0631",
            "party" : "democratic"
        ],
        [
            "district" : "0632",
            "party" : "democratic"
        ],
        [
            "district" : "0633",
            "party" : "democratic"
        ],
        [
            "district" : "0634",
            "party" : "democratic"
        ],
        [
            "district" : "0635",
            "party" : "democratic"
        ],
        [
            "district" : "0636",
            "party" : "democratic"
        ],
        [
            "district" : "0637",
            "party" : "democratic"
        ],
        [
            "district" : "0638",
            "party" : "democratic"
        ],
        [
            "district" : "0639",
            "party" : "democratic"
        ],
        [
            "district" : "0640",
            "party" : "democratic"
        ],
        [
            "district" : "0641",
            "party" : "democratic"
        ],
        [
            "district" : "0642",
            "party" : "republican"
        ],
        [
            "district" : "0643",
            "party" : "democratic"
        ],
        [
            "district" : "0644",
            "party" : "democratic"
        ],
        [
            "district" : "0645",
            "party" : "democratic"
        ],
        [
            "district" : "0646",
            "party" : "democratic"
        ],
        [
            "district" : "0647",
            "party" : "democratic"
        ],
        [
            "district" : "0648",
            "party" : "democratic"
        ],
        [
            "district" : "0649",
            "party" : "democratic"
        ],
        [
            "district" : "0650",
            "party" : "republican"
        ],
        [
            "district" : "0651",
            "party" : "democratic"
        ],
        [
            "district" : "0652",
            "party" : "democratic"
        ],
        [
            "district" : "0653",
            "party" : "democratic"
        ]
    ]
}
Was this page helpful?